boxworks/
tex.rs

1//! Tools for converting TeX's internal data structures to Boxworks.
2//!
3//! Knuth's TeX has a bunch of diagnostic tooling for
4//!     inspecting the internal data structures in its typesetting engine.
5//! This module contains functions that read the diagnostic output of Knuth's TeX
6//!     and reconstruct TeX's internal data structures
7//!     as Boxworks data structures.
8//! The initial motivation is to verify that
9//!     subsystems of TeX and Boxworks
10//!     -- like the subsystem that converts text into a horizontal list --
11//!     are doing the same thing.
12//!
13//! These functions require that TeX is installed
14//!     because they ultimately invoke TeX to generate the right diagnostic information.
15
16use crate::ds;
17use std::{collections::HashMap, path::PathBuf};
18
19/// Implementations of this trait can run TeX source code and return stdout.
20pub trait TexEngine {
21    /// Run the provided TeX source code and return stdout.
22    fn run(&self, tex_source_code: &str, auxiliary_files: &HashMap<PathBuf, Vec<u8>>) -> String;
23}
24
25/// Error return when a binary on the host computer was not found.
26#[derive(Clone, Debug)]
27pub struct BinaryNotFound {
28    /// Name of the binary e.g. `tex` or `pdftex`.
29    pub binary_name: String,
30}
31
32impl std::fmt::Display for BinaryNotFound {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        write!(
35            f,
36            "binary `{}` not found (`which {}` failed)",
37            &self.binary_name, &self.binary_name
38        )
39    }
40}
41
42impl std::error::Error for BinaryNotFound {}
43
44/// A TeX engine binary on the host computer, like `tex` or `pdftex`.
45pub fn new_tex_engine_binary(binary_name: String) -> Result<Box<dyn TexEngine>, BinaryNotFound> {
46    #[allow(clippy::expect_fun_call)]
47    if std::process::Command::new("which")
48        .arg(&binary_name)
49        .stdout(std::process::Stdio::null())
50        .spawn()
51        .expect(&format!["`which {binary_name}` command failed to start"])
52        .wait()
53        .expect(&format!["failed to run `which {binary_name}`"])
54        .success()
55    {
56        Ok(Box::new(TexEngineBinary(binary_name)))
57    } else {
58        Err(BinaryNotFound { binary_name })
59    }
60}
61
62struct TexEngineBinary(String);
63
64impl TexEngine for TexEngineBinary {
65    fn run(&self, tex_source_code: &str, auxiliary_files: &HashMap<PathBuf, Vec<u8>>) -> String {
66        let dir = std::env::temp_dir();
67
68        for (file_name, content) in auxiliary_files {
69            let mut path = dir.clone();
70            path.push(file_name);
71            eprintln!("writing to {}", path.as_os_str().to_string_lossy());
72            std::fs::write(&path, content).unwrap_or_else(|_| {
73                panic![
74                    "Unable to write auxiliary file {}",
75                    file_name.to_string_lossy()
76                ]
77            });
78        }
79
80        let mut input_path = dir.clone();
81        input_path.push("tex-input");
82        input_path.set_extension("tex");
83        eprintln!("writing to {}", input_path.as_os_str().to_string_lossy());
84        std::fs::write(&input_path, tex_source_code).expect("Unable to write file");
85
86        let output = std::process::Command::new(&self.0)
87            .current_dir(&dir)
88            .arg(&input_path)
89            .output()
90            .expect("failed to run tex command");
91        eprintln!("{}", String::from_utf8(output.stderr).unwrap());
92
93        let stdout = String::from_utf8(output.stdout).expect("stdout output of TeX is utf-8");
94        if !output.status.success() {
95            eprintln!("Warning: TeX command seems to have failed. Consult the logs in the log file (replace .tex input file with .log)");
96        }
97        stdout
98    }
99}
100
101/// Build horizontal lists from some text.
102///
103/// This function works by putting the text inside a TeX `\hbox{}`,
104///     and then instructing TeX to describe the contents of the box.
105///
106/// This function is the inverse of TeX.2021.173 and onwards
107/// (part 12 of TeX: displaying boxes).
108pub fn build_horizontal_lists(
109    tex_engine: &dyn TexEngine,
110    auxiliary_files: &HashMap<PathBuf, Vec<u8>>,
111    preamble: &str,
112    contents: &mut dyn Iterator<Item = &String>,
113) -> (HashMap<String, u32>, Vec<ds::HList>) {
114    let macro_calls: Vec<String> = contents.map(|s| format!(r#"\printBox{{{s}}}"#)).collect();
115    let tex_source_code = CONVERT_TEXT_TEMPLATE
116        .replace("<preamble>", preamble)
117        .replace("<box_template>", r"\hbox{#1}")
118        .replace("<print_calls>", &macro_calls.join("\n\n"));
119    let output = tex_engine.run(&tex_source_code, auxiliary_files);
120    let segments = extract_texcraft_segments(&output);
121    let mut fonts: HashMap<String, u32> = Default::default();
122    let hlists = segments
123        .map(|s| parse_hlist(&mut TexOutputIter::new(s), &mut fonts))
124        .collect();
125    (fonts, hlists)
126}
127
128/// Build verticals list from some text.
129///
130/// This function works by putting the text inside a TeX
131///     `\vbox{\noindent \hsize=41pt}`
132///     and then instructing TeX to describe the contents of the box.
133///
134/// This function is the inverse of TeX.2021.173 and onwards
135/// (part 12 of TeX: displaying boxes).
136pub fn build_vertical_lists(
137    tex_engine: &dyn TexEngine,
138    auxiliary_files: &HashMap<PathBuf, Vec<u8>>,
139    preamble: &str,
140    contents: &mut dyn Iterator<Item = &String>,
141) -> (HashMap<String, u32>, Vec<ds::VList>) {
142    let macro_calls: Vec<String> = contents.map(|s| format!(r#"\printBox{{{s}}}"#)).collect();
143    let tex_source_code = CONVERT_TEXT_TEMPLATE
144        .replace("<preamble>", preamble)
145        .replace("<box_template>", r"\vbox{\noindent \hsize=41pt #1}")
146        .replace("<print_calls>", &macro_calls.join("\n\n"));
147    let output = tex_engine.run(&tex_source_code, auxiliary_files);
148    let segments = extract_texcraft_segments(&output);
149    let mut fonts: HashMap<String, u32> = Default::default();
150    let vlists = segments
151        .map(|s| parse_vlist(&mut TexOutputIter::new(s), &mut fonts))
152        .collect();
153    (fonts, vlists)
154}
155
156struct TexOutputIter<'tex> {
157    s: &'tex str,
158    depth: usize,
159}
160
161impl<'tex> Iterator for TexOutputIter<'tex> {
162    type Item = &'tex str;
163
164    fn next(&mut self) -> Option<Self::Item> {
165        let (line, n) = self.peek_impl()?;
166        self.s = &self.s[n..];
167        Some(line)
168    }
169}
170
171impl<'tex> TexOutputIter<'tex> {
172    fn new(mut s: &'tex str) -> Self {
173        loop {
174            let line = s
175                .split_inclusive('\n')
176                .next()
177                .expect("still searching for start of output");
178            s = &s[line.len()..];
179            if !line.starts_with(r"> \box0=") {
180                continue;
181            }
182            return Self { s, depth: 0 };
183        }
184    }
185    fn inner(&self) -> Self {
186        Self {
187            s: self.s,
188            depth: self.depth + 1,
189        }
190    }
191    fn peek(&mut self) -> Option<&'tex str> {
192        let (line, _) = self.peek_impl()?;
193        Some(line)
194    }
195    fn peek_impl(&mut self) -> Option<(&'tex str, usize)> {
196        loop {
197            let line = self.s.split_inclusive('\n').next()?;
198            let n = line.len();
199            let line = line.trim_end();
200            if line.is_empty() {
201                self.s = "";
202                return None;
203            }
204            let line_depth = line.chars().take_while(|&c| c == '.').count();
205            use std::cmp::Ordering::*;
206            match line_depth.cmp(&self.depth) {
207                Less => {
208                    self.s = "";
209                    return None;
210                }
211                Equal => {
212                    return Some((&line[line_depth..], n));
213                }
214                Greater => {
215                    // skip this line
216                    self.s = &self.s[n..];
217                }
218            }
219        }
220    }
221}
222
223/// Extract the raw content between each pair of "Texcraft: begin" / "Texcraft: end" markers.
224///
225/// Yields one `&str` slice per begin/end pair, borrowing directly from `output`.
226fn extract_texcraft_segments(mut s: &str) -> impl Iterator<Item = &str> {
227    std::iter::from_fn(move || {
228        // Scan forward to the next "Texcraft: begin" line and record the segment start.
229        loop {
230            let line = s.split_inclusive('\n').next()?;
231            s = &s[line.len()..];
232            if line.starts_with("Texcraft: begin") {
233                break;
234            }
235        }
236        let next = s;
237        let mut next_len = 0_usize;
238        // Scan forward to the next "Texcraft: end" line and record the segment end.
239        loop {
240            let line = s.split_inclusive('\n').next()?;
241            s = &s[line.len()..];
242            next_len += line.len();
243            if line.starts_with("Texcraft: end") {
244                return Some(&next[0..next_len]);
245            }
246        }
247    })
248}
249
250/// Parse a single raw hlist segment (as produced by [`extract_hlist_segments`]) into an hlist.
251///
252/// The `fonts` map is updated in place as new fonts are encountered.
253fn parse_hlist(iter: &mut TexOutputIter, fonts: &mut HashMap<String, u32>) -> ds::HList {
254    let mut hlist = {
255        let line = iter.next().expect("hlist must be non-empty");
256        let s = line
257            .strip_prefix(r"\hbox(")
258            .expect("first line must be a hlist spec");
259        let i = s
260            .find('+')
261            .expect("hbox dimension spec has a + between height and depth");
262        let height = parse_scaled(&s[..i]);
263        let s = &s[i + 1..];
264        let i = s
265            .find(")x")
266            .expect("hbox dimension spec has a )x between depth and width");
267        let depth = parse_scaled(&s[..i]);
268        let rest = &s[i + 2..];
269        let (width_str, glue_set_str) = if let Some(j) = rest.find(", glue set ") {
270            (&rest[..j], Some(&rest[j + 11..]))
271        } else {
272            (rest, None)
273        };
274        let width = parse_scaled(width_str);
275        let (glue_ratio, glue_sign, glue_order) = glue_set_str.map(parse_glue_set).unwrap_or((
276            ds::GlueRatio(0.0),
277            ds::GlueSign::Normal,
278            core::GlueOrder::Normal,
279        ));
280        ds::HList {
281            height,
282            width,
283            depth,
284            glue_ratio,
285            glue_sign,
286            glue_order,
287            ..Default::default()
288        }
289    };
290    let mut iter = iter.inner();
291    while let Some(line) = iter.peek() {
292        let (keyword, tail) = keyword_and_tail(line);
293        let mut consume_line = true;
294        let elem: ds::Horizontal = match keyword {
295            "glue" => ds::Glue {
296                kind: ds::GlueKind::Normal,
297                value: parse_glue_value(tail),
298            }
299            .into(),
300            "kern" => {
301                let mut words = tail.split_ascii_whitespace();
302                let width = parse_scaled(words.next().expect("kern has 1 word"));
303                ds::Kern {
304                    kind: ds::KernKind::Normal,
305                    width,
306                }
307                .into()
308            }
309            "penalty" => {
310                let value: i32 = tail.trim().parse().expect("penalty value is i32");
311                ds::Penalty(value).into()
312            }
313            "rule" => {
314                let i = tail.find('x').expect("rule has a width");
315                ds::Rule {
316                    width: parse_scaled(&tail[i + 1..]),
317                    ..Default::default()
318                }
319                .into()
320            }
321            "discretionary" => {
322                // TODO: handle replacing_spec
323                _ = tail;
324                ds::Discretionary {
325                    pre_break: vec![],
326                    post_break: vec![],
327                    replace_count: 1,
328                }
329                .into()
330            }
331            "hbox" => {
332                consume_line = false;
333                parse_hlist(&mut iter, fonts).into()
334            }
335            "vbox" => {
336                consume_line = false;
337                parse_vlist(&mut iter, fonts).into()
338            }
339            font_name => {
340                use std::collections::hash_map::Entry;
341                let num_fonts: u32 = fonts.len().try_into().expect("no more than 2^32 fonts");
342                let font = match fonts.entry(font_name.to_string()) {
343                    Entry::Occupied(occupied_entry) => *occupied_entry.get(),
344                    Entry::Vacant(vacant_entry) => {
345                        vacant_entry.insert(num_fonts);
346                        num_fonts
347                    }
348                };
349                let mut words = tail.split_ascii_whitespace();
350                let char =
351                    parse_char(words.next().unwrap_or_else(|| {
352                        panic!("expected char after font command \\{font_name}")
353                    }));
354                if words.next() == Some("(ligature") {
355                    let og_chars = words.next().expect("lig has 4 words");
356                    let og_chars = og_chars.strip_suffix(")").expect("lig ends with ')'");
357                    ds::Ligature {
358                        included_left_boundary: false,
359                        included_right_boundary: false,
360                        char,
361                        font,
362                        original_chars: og_chars.into(),
363                    }
364                    .into()
365                } else {
366                    ds::Char { char, font }.into()
367                }
368            }
369        };
370        hlist.list.push(elem);
371        if consume_line {
372            iter.next();
373        }
374    }
375    hlist
376}
377
378/// Parse a single raw vlist segment into a vlist.
379fn parse_vlist(iter: &mut TexOutputIter, fonts: &mut HashMap<String, u32>) -> ds::VList {
380    let mut vlist = {
381        let line = iter.next().expect("vlist must be non-empty");
382        let s = line
383            .strip_prefix(r"\vbox(")
384            .expect("first line must be a vlist spec");
385        let i = s
386            .find('+')
387            .expect("vbox dimension spec has a + between height and depth");
388        let height = parse_scaled(&s[..i]);
389        let s = &s[i + 1..];
390        let i = s
391            .find(")x")
392            .expect("vbox dimension spec has a )x between depth and width");
393        let depth = parse_scaled(&s[..i]);
394        let width = parse_scaled(&s[i + 2..]);
395        ds::VList {
396            height,
397            width,
398            depth,
399            shift_amount: core::Scaled::ZERO,
400            list: vec![],
401            glue_ratio: ds::GlueRatio(0.0),
402            glue_sign: ds::GlueSign::Normal,
403            glue_order: core::GlueOrder::Normal,
404        }
405    };
406    let mut iter = iter.inner();
407    while let Some(line) = iter.peek() {
408        let (keyword, tail) = keyword_and_tail(line);
409        let mut consume_line = true;
410        let elem: ds::Vertical = match keyword {
411            "hbox" => {
412                consume_line = false;
413                parse_hlist(&mut iter, fonts).into()
414            }
415            "penalty" => {
416                let value: i32 = tail.trim().parse().expect("penalty value is i32");
417                ds::Penalty(value).into()
418            }
419            "glue" => ds::Vertical::Glue(ds::Glue {
420                kind: ds::GlueKind::Normal,
421                value: parse_glue_value(tail),
422            }),
423            _ => unimplemented!("vlist keyword {keyword} is not implemented"),
424        };
425        vlist.list.push(elem);
426        if consume_line {
427            iter.next();
428        }
429    }
430    vlist
431}
432
433fn keyword_and_tail(s: &str) -> (&str, &str) {
434    let mut c = s.chars();
435    assert_eq!(c.next(), Some('\\'), "line expected to begin with \\");
436    let mut keyword_len = 0_usize;
437    for next in c {
438        if next.is_alphabetic() {
439            keyword_len += next.len_utf8();
440        } else {
441            break;
442        }
443    }
444    (&s[1..1 + keyword_len], s[1 + keyword_len..].trim())
445}
446
447/// Parse the glue value from the text following `\glue` in TeX's box display.
448///
449/// `spec` may start with a parenthesised name (named glue, e.g. `(\rightskip) 0.0`)
450/// or directly with a space followed by the width (e.g. ` 3.33333 plus 1.66666 minus 1.11111`).
451fn parse_glue_value(spec: &str) -> core::Glue {
452    let spec = if spec.starts_with('(') {
453        let close = spec.find(')').expect("named glue has ')'");
454        spec[close + 1..].trim_start()
455    } else {
456        spec.trim_start()
457    };
458    let mut words = spec.split_ascii_whitespace();
459    let width = parse_scaled(words.next().expect("glue has a width"));
460    let (stretch, stretch_order) = if words.next() == Some("plus") {
461        parse_glue_amount(words.next().expect("glue has stretch after plus"))
462    } else {
463        (core::Scaled::ZERO, core::GlueOrder::Normal)
464    };
465    let (shrink, shrink_order) = if words.next() == Some("minus") {
466        parse_glue_amount(words.next().expect("glue has shrink after minus"))
467    } else {
468        (core::Scaled::ZERO, core::GlueOrder::Normal)
469    };
470    core::Glue {
471        width,
472        stretch,
473        stretch_order,
474        shrink,
475        shrink_order,
476    }
477}
478
479/// Parse a glue component like `"1.66666"`, `"1.0fil"`, `"0.0fill"`.
480fn parse_glue_amount(s: &str) -> (core::Scaled, core::GlueOrder) {
481    if let Some(s) = s.strip_suffix("filll") {
482        (parse_scaled(s), core::GlueOrder::Filll)
483    } else if let Some(s) = s.strip_suffix("fill") {
484        (parse_scaled(s), core::GlueOrder::Fill)
485    } else if let Some(s) = s.strip_suffix("fil") {
486        (parse_scaled(s), core::GlueOrder::Fil)
487    } else {
488        (parse_scaled(s), core::GlueOrder::Normal)
489    }
490}
491
492/// Parse the `"N[order]"` text that follows `", glue set "` on an hbox line.
493fn parse_glue_set(s: &str) -> (ds::GlueRatio, ds::GlueSign, core::GlueOrder) {
494    let (sign, s) = if let Some(s) = s.strip_prefix("- ") {
495        (ds::GlueSign::Shrinking, s)
496    } else {
497        (ds::GlueSign::Stretching, s)
498    };
499    let (s, order) = if let Some(s) = s.strip_suffix("filll") {
500        (s, core::GlueOrder::Filll)
501    } else if let Some(s) = s.strip_suffix("fill") {
502        (s, core::GlueOrder::Fill)
503    } else if let Some(s) = s.strip_suffix("fil") {
504        (s, core::GlueOrder::Fil)
505    } else {
506        (s, core::GlueOrder::Normal)
507    };
508    let ratio: f32 = s.parse().expect("glue set ratio is a float");
509    (ds::GlueRatio(ratio), sign, order)
510}
511
512fn parse_char(s: &str) -> char {
513    let mut cs = s.chars();
514    match cs.next().expect("char has one character") {
515        '^' => {
516            assert_eq!(cs.next(), Some('^'));
517            let raw_c = cs.next().expect("char of the form ^^X") as u32;
518            if let Some(raw_c) = raw_c.checked_sub(64) {
519                raw_c
520            } else {
521                raw_c + 64
522            }
523            .try_into()
524            .expect("TeX describes a valid character")
525        }
526        c => c,
527    }
528}
529
530fn parse_scaled(s: &str) -> core::Scaled {
531    let (neg, s) = match s.strip_prefix('-') {
532        Some(s) => (true, s),
533        None => (false, s),
534    };
535    let mut parts = s.split('.');
536    let i: i32 = parts
537        .next()
538        .expect("scaled has an integer part")
539        .parse()
540        .expect("integer part is an integer");
541    let mut f = [0_u8; 17];
542    for (k, c) in parts
543        .next()
544        .expect("scaled has a fractional part")
545        .chars()
546        .enumerate()
547    {
548        f[k] = c
549            .to_digit(10)
550            .expect("fractional part are digits")
551            .try_into()
552            .expect("digits are in the range [0,10) and always fit in u8");
553    }
554    let f = core::Scaled::from_decimal_digits(&f);
555    let sc = core::Scaled::new(i, f, core::ScaledUnit::Point).unwrap_or_else(|_| {
556        eprintln!(
557            "scaled number '{s}pt' is too big to parse; replacing with the largest scaled value {}",
558            core::Scaled::MAX_DIMEN
559        );
560        core::Scaled::MAX_DIMEN
561    });
562    if neg {
563        -sc
564    } else {
565        sc
566    }
567}
568
569const CONVERT_TEXT_TEMPLATE: &str = r"
570
571% User provided preamble.
572<preamble>
573
574% After showing a box, TeX stops and waits for user input.
575% The following command suppresses that behavior.
576\nonstopmode
577
578% Output the box description to the terminal, from which we'll read it.
579\tracingonline=1
580
581% Output up to 1 million nodes.
582\showboxbreadth=1000000
583% Output up to 100 nested boxes.
584\showboxdepth=100
585
586% Prints the contents on its own line in the terminal.
587\def\fullLineMessage#1{
588    {
589        \newlinechar=`@
590        \message{@#1@}
591    }
592}
593
594\def\printBox#1{
595    % Put the content we want to see in box 0.
596    \setbox0=<box_template>
597    % Add a start marker so we know where to begin in the log
598    \fullLineMessage{Texcraft: begin}
599    % Show the box!
600    \showbox0
601
602    % Add a start marker so we know where to end in the log
603    \fullLineMessage{Texcraft: end}
604}
605
606<print_calls>
607
608We add some text at the end.
609
610\end
611";
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    struct MockTexEngine(String);
618
619    impl TexEngine for MockTexEngine {
620        fn run(&self, _: &str, _: &HashMap<PathBuf, Vec<u8>>) -> String {
621            self.0.clone()
622        }
623    }
624
625    #[test]
626    fn test_build_horizontal_lists() {
627        let log = r#"This is TeX, Version 3.141592653 (TeX Live 2024) (preloaded format=tex)
628(./tmp/test.tex
629
630Texcraft: begin
631> \box0=
632\hbox(6.94444+0.0)x56.66678
633.\tenrm M
634.\tenrm i
635.\tenrm n
636.\kern-0.27779
637.\tenrm t
638.\glue 3.33333 plus 1.66666 minus 1.11111
639.\tenrm a
640.\tenrm n
641.\tenrm d
642.\glue 3.33333 plus 1.66666 minus 1.11111
643.\tenrm m
644.\tenrm e
645
646! OK.
647\printBox ...Message {Texcraft: begin} \showbox 0 
648                                                  \par \fullLineMessage {Tex...
649l.31 \printBox{Mint and me}
650                           
651
652Texcraft: end
653 )
654(see the transcript file for additional information)
655No pages of output.
656Transcript written on test.log.
657"#;
658
659        let tex_engine = MockTexEngine(log.to_string());
660        let (got_fonts, got_list) = build_horizontal_lists(
661            &tex_engine,
662            &Default::default(),
663            &"",
664            &mut vec!["".to_string()].iter(),
665        );
666
667        let want_list = ds::HList {
668            height: parse_scaled("6.94444"),
669            width: parse_scaled("56.66678"),
670            depth: core::Scaled::ZERO,
671            list: vec![
672                ds::Char { char: 'M', font: 0 }.into(),
673                ds::Char { char: 'i', font: 0 }.into(),
674                ds::Char { char: 'n', font: 0 }.into(),
675                ds::Kern {
676                    kind: ds::KernKind::Normal,
677                    width: parse_scaled("-0.27779"),
678                }
679                .into(),
680                ds::Char { char: 't', font: 0 }.into(),
681                ds::Glue {
682                    kind: ds::GlueKind::Normal,
683                    value: core::Glue {
684                        width: parse_scaled("3.33333"),
685                        stretch: parse_scaled("1.66666"),
686                        stretch_order: core::GlueOrder::Normal,
687                        shrink: parse_scaled("1.11111"),
688                        shrink_order: core::GlueOrder::Normal,
689                    },
690                }
691                .into(),
692                ds::Char { char: 'a', font: 0 }.into(),
693                ds::Char { char: 'n', font: 0 }.into(),
694                ds::Char { char: 'd', font: 0 }.into(),
695                ds::Glue {
696                    kind: ds::GlueKind::Normal,
697                    value: core::Glue {
698                        width: parse_scaled("3.33333"),
699                        stretch: parse_scaled("1.66666"),
700                        stretch_order: core::GlueOrder::Normal,
701                        shrink: parse_scaled("1.11111"),
702                        shrink_order: core::GlueOrder::Normal,
703                    },
704                }
705                .into(),
706                ds::Char { char: 'm', font: 0 }.into(),
707                ds::Char { char: 'e', font: 0 }.into(),
708            ],
709            ..Default::default()
710        };
711        let want_fonts = {
712            let mut m = HashMap::new();
713            m.insert("tenrm".to_string(), 0);
714            m
715        };
716
717        assert_eq!(got_list, vec![want_list]);
718        assert_eq!(got_fonts, want_fonts);
719    }
720
721    #[test]
722    fn test_build_vertical_lists() {
723        let log = r#"
724This is TeX, Version 3.141592653 (TeX Live 2024) (preloaded format=tex)
725(./test.tex
726
727Texcraft: begin
728> \box0=
729\vbox(18.94444+0.0)x41.0
730.\hbox(6.94444+0.0)x41.0, glue set 0.26662
731..\tenrm M
732..\tenrm i
733..\tenrm n
734..\kern-0.27779
735..\tenrm t
736..\glue 3.33333 plus 1.66666 minus 1.11111
737..\tenrm a
738..\tenrm n
739..\tenrm d
740..\glue(\rightskip) 0.0
741.\penalty 300
742.\glue(\baselineskip) 7.69446
743.\hbox(4.30554+0.0)x41.0, glue set 28.2222fil
744..\tenrm m
745..\tenrm e
746..\penalty 10000
747..\glue(\parfillskip) 0.0 plus 1.0fil
748..\glue(\rightskip) 0.0
749
750! OK.
751\printBox ...Message {Texcraft: begin} \showbox 0 
752                                                  \par \fullLineMessage {Tex...
753l.31 \printBox{Mint and me}
754                           
755
756Texcraft: end
757 )
758(see the transcript file for additional information)
759No pages of output.
760Transcript written on test.log.
761"#;
762
763        let tex_engine = MockTexEngine(log.to_string());
764        let (got_fonts, got_list) = build_vertical_lists(
765            &tex_engine,
766            &Default::default(),
767            &"",
768            &mut vec!["".to_string()].iter(),
769        );
770
771        let want_list = ds::VList {
772            height: parse_scaled("18.94444"),
773            width: parse_scaled("41.0"),
774            depth: core::Scaled::ZERO,
775            shift_amount: core::Scaled::ZERO,
776            glue_ratio: ds::GlueRatio(0.0),
777            glue_sign: ds::GlueSign::Normal,
778            glue_order: core::GlueOrder::Normal,
779            list: vec![
780                ds::Vertical::HList(ds::HList {
781                    height: parse_scaled("6.94444"),
782                    width: parse_scaled("41.0"),
783                    depth: core::Scaled::ZERO,
784                    glue_ratio: ds::GlueRatio(0.26662),
785                    glue_sign: ds::GlueSign::Stretching,
786                    glue_order: core::GlueOrder::Normal,
787                    list: vec![
788                        ds::Char { char: 'M', font: 0 }.into(),
789                        ds::Char { char: 'i', font: 0 }.into(),
790                        ds::Char { char: 'n', font: 0 }.into(),
791                        ds::Kern {
792                            kind: ds::KernKind::Normal,
793                            width: parse_scaled("-0.27779"),
794                        }
795                        .into(),
796                        ds::Char { char: 't', font: 0 }.into(),
797                        ds::Glue {
798                            kind: ds::GlueKind::Normal,
799                            value: core::Glue {
800                                width: parse_scaled("3.33333"),
801                                stretch: parse_scaled("1.66666"),
802                                stretch_order: core::GlueOrder::Normal,
803                                shrink: parse_scaled("1.11111"),
804                                shrink_order: core::GlueOrder::Normal,
805                            },
806                        }
807                        .into(),
808                        ds::Char { char: 'a', font: 0 }.into(),
809                        ds::Char { char: 'n', font: 0 }.into(),
810                        ds::Char { char: 'd', font: 0 }.into(),
811                        ds::Glue {
812                            kind: ds::GlueKind::Normal,
813                            value: core::Glue {
814                                width: core::Scaled::ZERO,
815                                stretch: core::Scaled::ZERO,
816                                stretch_order: core::GlueOrder::Normal,
817                                shrink: core::Scaled::ZERO,
818                                shrink_order: core::GlueOrder::Normal,
819                            },
820                        }
821                        .into(),
822                    ],
823                    ..Default::default()
824                }),
825                ds::Vertical::Penalty(ds::Penalty(300)),
826                ds::Vertical::Glue(ds::Glue {
827                    kind: ds::GlueKind::Normal,
828                    value: core::Glue {
829                        width: parse_scaled("7.69446"),
830                        stretch: core::Scaled::ZERO,
831                        stretch_order: core::GlueOrder::Normal,
832                        shrink: core::Scaled::ZERO,
833                        shrink_order: core::GlueOrder::Normal,
834                    },
835                }),
836                ds::Vertical::HList(ds::HList {
837                    height: parse_scaled("4.30554"),
838                    width: parse_scaled("41.0"),
839                    depth: core::Scaled::ZERO,
840                    glue_ratio: ds::GlueRatio(28.2222),
841                    glue_sign: ds::GlueSign::Stretching,
842                    glue_order: core::GlueOrder::Fil,
843                    list: vec![
844                        ds::Char { char: 'm', font: 0 }.into(),
845                        ds::Char { char: 'e', font: 0 }.into(),
846                        ds::Penalty(10000).into(),
847                        ds::Glue {
848                            kind: ds::GlueKind::Normal,
849                            value: core::Glue {
850                                width: core::Scaled::ZERO,
851                                stretch: parse_scaled("1.0"),
852                                stretch_order: core::GlueOrder::Fil,
853                                shrink: core::Scaled::ZERO,
854                                shrink_order: core::GlueOrder::Normal,
855                            },
856                        }
857                        .into(),
858                        ds::Glue {
859                            kind: ds::GlueKind::Normal,
860                            value: core::Glue {
861                                width: core::Scaled::ZERO,
862                                stretch: core::Scaled::ZERO,
863                                stretch_order: core::GlueOrder::Normal,
864                                shrink: core::Scaled::ZERO,
865                                shrink_order: core::GlueOrder::Normal,
866                            },
867                        }
868                        .into(),
869                    ],
870                    ..Default::default()
871                }),
872            ],
873        };
874        let want_fonts = {
875            let mut m = HashMap::new();
876            m.insert("tenrm".to_string(), 0);
877            m
878        };
879
880        assert_eq!(got_list, vec![want_list]);
881        assert_eq!(got_fonts, want_fonts);
882    }
883    #[test]
884    fn test_build_vertical_lists_2() {
885        let log = r#"
886This is TeX, Version 3.141592653 (TeX Live 2024) (preloaded format=tex)
887(./test.tex
888
889Texcraft: begin
890> \box0=
891\vbox(6.83331+0.0)x41.0
892.\hbox(6.83331+0.0)x41.0
893..\tenrm A
894..\hbox(6.83331+0.0)x48.08336
895...\tenrm B
896...\vbox(6.83331+0.0)x41.0
897....\hbox(6.83331+0.0)x41.0, glue set 6.13887fil
898.....\hbox(0.0+0.0)x20.0
899.....\tenrm C
900.....\hbox(6.83331+0.0)x7.6389
901......\tenrm D
902.....\penalty 10000
903.....\glue(\parfillskip) 0.0 plus 1.0fil
904.....\glue(\rightskip) 0.0
905..\penalty 10000
906..\glue(\parfillskip) 0.0 plus 1.0fil
907..\glue(\rightskip) 0.0
908..\rule(*+*)x5.0
909
910! OK.
911\printBox ...Message {Texcraft: begin} \showbox 0 
912                                                  \par \fullLineMessage {Tex...
913l.31 \printBox{Mint and me}
914                           
915
916Texcraft: end
917 )
918(see the transcript file for additional information)
919No pages of output.
920Transcript written on test.log.
921"#;
922
923        let tex_engine = MockTexEngine(log.to_string());
924        let (got_fonts, got_list) = build_vertical_lists(
925            &tex_engine,
926            &Default::default(),
927            &"",
928            &mut vec!["".to_string()].iter(),
929        );
930
931        let want_list = ds::VList {
932            height: parse_scaled("6.83331"),
933            width: parse_scaled("41.0"),
934            depth: core::Scaled::ZERO,
935            shift_amount: core::Scaled::ZERO,
936            glue_ratio: ds::GlueRatio(0.0),
937            glue_sign: ds::GlueSign::Normal,
938            glue_order: core::GlueOrder::Normal,
939            list: vec![ds::Vertical::HList(ds::HList {
940                height: parse_scaled("6.83331"),
941                width: parse_scaled("41.0"),
942                depth: core::Scaled::ZERO,
943                list: vec![
944                    ds::Char { char: 'A', font: 0 }.into(),
945                    ds::Horizontal::HList(ds::HList {
946                        height: parse_scaled("6.83331"),
947                        width: parse_scaled("48.08336"),
948                        depth: core::Scaled::ZERO,
949                        list: vec![
950                            ds::Char { char: 'B', font: 0 }.into(),
951                            ds::Horizontal::VList(ds::VList {
952                                height: parse_scaled("6.83331"),
953                                width: parse_scaled("41.0"),
954                                depth: core::Scaled::ZERO,
955                                list: vec![ds::Vertical::HList(ds::HList {
956                                    height: parse_scaled("6.83331"),
957                                    width: parse_scaled("41.0"),
958                                    depth: core::Scaled::ZERO,
959                                    glue_ratio: ds::GlueRatio(6.13887),
960                                    glue_sign: ds::GlueSign::Stretching,
961                                    glue_order: core::GlueOrder::Fil,
962                                    list: vec![
963                                        ds::Horizontal::HList(ds::HList {
964                                            height: core::Scaled::ZERO,
965                                            width: parse_scaled("20.0"),
966                                            depth: core::Scaled::ZERO,
967                                            ..Default::default()
968                                        }),
969                                        ds::Char { char: 'C', font: 0 }.into(),
970                                        ds::Horizontal::HList(ds::HList {
971                                            height: parse_scaled("6.83331"),
972                                            width: parse_scaled("7.6389"),
973                                            depth: core::Scaled::ZERO,
974                                            list: vec![ds::Char { char: 'D', font: 0 }.into()],
975                                            ..Default::default()
976                                        }),
977                                        ds::Penalty(10000).into(),
978                                        ds::Glue {
979                                            kind: ds::GlueKind::Normal,
980                                            value: core::Glue {
981                                                width: core::Scaled::ZERO,
982                                                stretch: parse_scaled("1.0"),
983                                                stretch_order: core::GlueOrder::Fil,
984                                                shrink: core::Scaled::ZERO,
985                                                shrink_order: core::GlueOrder::Normal,
986                                            },
987                                        }
988                                        .into(),
989                                        ds::Glue {
990                                            kind: ds::GlueKind::Normal,
991                                            value: core::Glue {
992                                                width: core::Scaled::ZERO,
993                                                stretch: core::Scaled::ZERO,
994                                                stretch_order: core::GlueOrder::Normal,
995                                                shrink: core::Scaled::ZERO,
996                                                shrink_order: core::GlueOrder::Normal,
997                                            },
998                                        }
999                                        .into(),
1000                                    ],
1001                                    ..Default::default()
1002                                })],
1003                                ..Default::default()
1004                            }),
1005                        ],
1006                        ..Default::default()
1007                    }),
1008                    ds::Penalty(10000).into(),
1009                    ds::Glue {
1010                        kind: ds::GlueKind::Normal,
1011                        value: core::Glue {
1012                            width: core::Scaled::ZERO,
1013                            stretch: parse_scaled("1.0"),
1014                            stretch_order: core::GlueOrder::Fil,
1015                            shrink: core::Scaled::ZERO,
1016                            shrink_order: core::GlueOrder::Normal,
1017                        },
1018                    }
1019                    .into(),
1020                    ds::Glue {
1021                        kind: ds::GlueKind::Normal,
1022                        value: core::Glue {
1023                            width: core::Scaled::ZERO,
1024                            stretch: core::Scaled::ZERO,
1025                            stretch_order: core::GlueOrder::Normal,
1026                            shrink: core::Scaled::ZERO,
1027                            shrink_order: core::GlueOrder::Normal,
1028                        },
1029                    }
1030                    .into(),
1031                    ds::Rule {
1032                        height: ds::Rule::RUNNING,
1033                        depth: ds::Rule::RUNNING,
1034                        width: parse_scaled("5.0"),
1035                    }
1036                    .into(),
1037                ],
1038                ..Default::default()
1039            })],
1040        };
1041        let want_fonts = {
1042            let mut m = HashMap::new();
1043            m.insert("tenrm".to_string(), 0);
1044            m
1045        };
1046
1047        assert_eq!(got_fonts, want_fonts);
1048        assert_eq!(got_list, vec![want_list]);
1049    }
1050}