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    ///
23    /// The method accepts a mutable reference to that implementations of this trait
24    /// can assume there are not concurrent runs of the engine. This is useful for real
25    /// TeX engines which need to write files to disk.
26    fn run(&mut self, tex_source_code: &str, auxiliary_files: &HashMap<PathBuf, Vec<u8>>)
27        -> String;
28}
29
30/// Error return when a binary on the host computer was not found.
31#[derive(Clone, Debug)]
32pub struct BinaryNotFound {
33    /// Name of the binary e.g. `tex` or `pdftex`.
34    pub binary_name: String,
35}
36
37impl std::fmt::Display for BinaryNotFound {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(
40            f,
41            "binary `{}` not found (`which {}` failed)",
42            &self.binary_name, &self.binary_name
43        )
44    }
45}
46
47impl std::error::Error for BinaryNotFound {}
48
49/// A TeX engine binary on the host computer, like `tex` or `pdftex`.
50pub fn new_tex_engine_binary(binary_name: String) -> Result<Box<dyn TexEngine>, BinaryNotFound> {
51    #[allow(clippy::expect_fun_call)]
52    if !std::process::Command::new("which")
53        .arg(&binary_name)
54        .stdout(std::process::Stdio::null())
55        .spawn()
56        .expect(&format!["`which {binary_name}` command failed to start"])
57        .wait()
58        .expect(&format!["failed to run `which {binary_name}`"])
59        .success()
60    {
61        return Err(BinaryNotFound { binary_name });
62    }
63    #[cfg(feature = "tempfile")]
64    {
65        let tempfile_handle = tempfile::TempDir::new().unwrap();
66        let dir: std::path::PathBuf = tempfile_handle.path().into();
67        Ok(Box::new(TexEngineBinary {
68            binary_name,
69            _tempfile_handle: tempfile_handle,
70            dir,
71        }))
72    }
73    #[cfg(not(feature = "tempfile"))]
74    {
75        Ok(Box::new(TexEngineBinary {
76            binary_name,
77            dir: std::env::temp_dir(),
78        }))
79    }
80}
81
82struct TexEngineBinary {
83    binary_name: String,
84    #[cfg(feature = "tempfile")]
85    _tempfile_handle: tempfile::TempDir,
86    dir: std::path::PathBuf,
87}
88
89impl TexEngine for TexEngineBinary {
90    fn run(
91        &mut self,
92        tex_source_code: &str,
93        auxiliary_files: &HashMap<PathBuf, Vec<u8>>,
94    ) -> String {
95        let mut dir = self.dir.clone();
96        let thread = std::thread::current();
97        let thread_name = thread
98            .name()
99            .unwrap_or("texcraft_unknown_thread_name")
100            .replace("::", "__");
101        dir.push("texcraft_tex");
102        dir.push(thread_name);
103        std::fs::create_dir_all(&dir).unwrap();
104
105        for (file_name, content) in auxiliary_files {
106            let mut path = dir.clone();
107            path.push(file_name);
108            eprintln!("writing to {}", path.as_os_str().to_string_lossy());
109            std::fs::write(&path, content).unwrap_or_else(|_| {
110                panic![
111                    "Unable to write auxiliary file {}",
112                    file_name.to_string_lossy()
113                ]
114            });
115        }
116
117        let mut input_path = dir.clone();
118        input_path.push("tex-input");
119        input_path.set_extension("tex");
120        eprintln!("writing to {}", input_path.as_os_str().to_string_lossy());
121        std::fs::write(&input_path, tex_source_code).expect("Unable to write file");
122
123        let output = std::process::Command::new(&self.binary_name)
124            .current_dir(&dir)
125            // By default TeX wraps terminal output at 79 characters, which
126            // would split long diagnostic lines such as those in the
127            // \tracingparagraphs trace. The output parsers in this module and
128            // its consumers assume unwrapped lines.
129            .env("max_print_line", "10000")
130            .arg(&input_path)
131            .output()
132            .expect("failed to run tex command");
133        eprintln!("{}", String::from_utf8(output.stderr).unwrap());
134
135        let stdout = String::from_utf8(output.stdout).expect("stdout output of TeX is utf-8");
136        if !output.status.success() {
137            eprintln!("Warning: TeX command seems to have failed. Consult the logs in the log file (replace .tex input file with .log)");
138        }
139        stdout
140    }
141}
142
143/// A [`TexEngine`] that records the terminal output of the most recent run
144/// of the inner engine.
145///
146/// This is useful for inspecting diagnostic output (like the
147/// `\tracingparagraphs` trace; see [`extract_paragraph_trace`]) that the
148/// functions in this module don't themselves return.
149pub struct RecordingTexEngine {
150    inner: Box<dyn TexEngine>,
151    stdout: String,
152}
153
154impl RecordingTexEngine {
155    pub fn new(inner: Box<dyn TexEngine>) -> Self {
156        Self {
157            inner,
158            stdout: String::new(),
159        }
160    }
161    /// Returns the terminal output of the most recent run.
162    pub fn stdout(&self) -> &str {
163        &self.stdout
164    }
165}
166
167impl TexEngine for RecordingTexEngine {
168    fn run(
169        &mut self,
170        tex_source_code: &str,
171        auxiliary_files: &HashMap<PathBuf, Vec<u8>>,
172    ) -> String {
173        self.stdout = self.inner.run(tex_source_code, auxiliary_files);
174        self.stdout.clone()
175    }
176}
177
178/// Returns the TeX preamble to use when comparing Boxworks output to TeX's.
179///
180/// The preamble enables `\tracingparagraphs`, works around diagnostics that
181/// Boxworks does not implement yet, and activates the font whose metrics
182/// file (e.g. `cmr10.tfm` for a `font_file_stem` of `cmr10`) must be
183/// provided as an auxiliary file when the engine runs.
184pub fn diagnostic_preamble(font_file_stem: &str) -> String {
185    format!(
186        r"
187\tracingparagraphs=1
188
189% Boxworks does not yet append the \overfullrule rule to overfull boxes
190% (TeX.2021.666), so suppress it in TeX's output for now.
191\hfuzz=\maxdimen
192
193\font \customFont {font_file_stem}
194
195\customFont
196"
197    )
198}
199
200/// Extracts the `\tracingparagraphs` trace from TeX's terminal output:
201/// everything from `@firstpass` (or `@secondpass`, if the first pass is
202/// skipped) up to the end of the trace.
203///
204/// After the trace, TeX may print underfull/overfull box warnings when it
205/// packages the broken lines (TeX.2021.660 and TeX.2021.663). These are
206/// not part of the line breaking trace, so extraction stops there too.
207pub fn extract_paragraph_trace(stdout: &str) -> String {
208    let mut lines = vec![];
209    let mut in_trace = false;
210    for line in stdout.lines() {
211        if !in_trace && (line.starts_with("@firstpass") || line.starts_with("@secondpass")) {
212            in_trace = true;
213        }
214        if line.starts_with("Texcraft: begin")
215            || [
216                "Underfull \\hbox",
217                "Overfull \\hbox",
218                "Loose \\hbox",
219                "Tight \\hbox",
220            ]
221            .iter()
222            .any(|warning| line.starts_with(warning))
223        {
224            break;
225        }
226        if in_trace {
227            lines.push(line);
228        }
229    }
230    lines.join("\n")
231}
232
233/// Prepends `\looseness=<looseness>` to the provided text.
234///
235/// TeX resets `\looseness` to zero after every paragraph, so setting it in
236/// the preamble only affects the first paragraph. To apply a looseness value
237/// to every paragraph, transform each text with this function.
238///
239/// The number is terminated with an empty group rather than a space:
240/// otherwise, if the text starts with a space, TeX's number scanner would
241/// consume it and the text would lose its leading interword glue.
242pub fn prepend_looseness(looseness: i32, text: &str) -> String {
243    format![r"\looseness={looseness}{{}}{text}"]
244}
245
246const HBOX_TEMPLATE: &str = include_str!("hbox_template.tex");
247
248/// Build horizontal lists from some text.
249///
250/// This function works by putting the text inside a TeX `\hbox{}`,
251///     and then instructing TeX to describe the contents of the box.
252///
253/// This function is the inverse of TeX.2021.173 and onwards
254/// (part 12 of TeX: displaying boxes).
255pub fn build_horizontal_lists(
256    tex_engine: &mut dyn TexEngine,
257    auxiliary_files: &HashMap<PathBuf, Vec<u8>>,
258    preamble: &str,
259    contents: &mut dyn Iterator<Item = &String>,
260    hyphenate: bool,
261) -> (HashMap<String, common::FontId>, Vec<ds::HBox>) {
262    let macro_calls: Vec<String> = contents
263        .map(|s| format!(r#"\buildAndPrintBoxes{{{s}}}"#))
264        .collect();
265    let tex_source_code = HBOX_TEMPLATE
266        .replace("<preamble>", preamble)
267        .replace("<print_calls>", &macro_calls.join("\n\n"));
268    let output = tex_engine.run(&tex_source_code, auxiliary_files);
269
270    let mut fonts: HashMap<String, common::FontId> = Default::default();
271    let mut tail: &str = &output;
272    let mut line_number = 0_usize;
273    enum Next {
274        First,
275        Second(ds::HBox),
276        Third(ds::HBox, ds::HBox),
277    }
278    let mut next = Next::First;
279    let mut h_boxes = vec![];
280    while let Some(line) = tail.split_inclusive('\n').next() {
281        line_number += 1;
282        tail = &tail[line.len()..];
283        if !line.trim().starts_with(match next {
284            Next::First => r"> \box253=",
285            Next::Second(_) => "### horizontal mode entered at line",
286            Next::Third(_, _) => "### current page:",
287        }) {
288            continue;
289        }
290        let mut iter = TexOutputIter {
291            s: tail,
292            depth: 0,
293            line_number,
294        };
295        next = match next {
296            Next::First => Next::Second(parse_h_box(&mut iter, &mut fonts).unwrap()),
297            Next::Second(h_box_1) => {
298                let h_box_2_list = parse_h_box_list(&mut iter, &mut fonts).unwrap();
299                let h_box_2 = ds::HBox {
300                    height: h_box_1.height,
301                    width: h_box_1.width,
302                    depth: h_box_1.depth,
303                    shift_amount: h_box_1.shift_amount,
304                    list: h_box_2_list,
305                    glue_ratio: h_box_1.glue_ratio,
306                    glue_order: h_box_1.glue_order,
307                };
308                Next::Third(h_box_1, h_box_2)
309            }
310            Next::Third(h_box_1, h_box_2) => {
311                let mut list = parse_v_box_list(&mut iter, &mut fonts).unwrap();
312                let h_box_3_list = match list.remove(1) {
313                    ds::Vertical::HBox(h_box_3) => {
314                        let mut list = h_box_3.list;
315                        // Remove the 3 elements the line breaking algorithm adds.
316                        list.pop();
317                        list.pop();
318                        list.pop();
319                        list
320                    }
321                    _ => panic!("expected h_box, got {:?}", list[1]),
322                };
323                let h_box_3 = ds::HBox {
324                    height: h_box_1.height,
325                    width: h_box_1.width,
326                    depth: h_box_1.depth,
327                    shift_amount: h_box_1.shift_amount,
328                    list: h_box_3_list,
329                    glue_ratio: h_box_1.glue_ratio,
330                    glue_order: h_box_1.glue_order,
331                };
332                h_boxes.push(if hyphenate { h_box_3 } else { h_box_2 });
333                Next::First
334            }
335        };
336    }
337    (fonts, h_boxes)
338}
339
340/// Build verticals list from some text.
341///
342/// This function works by putting the text inside a TeX
343///     `\vbox{\noindent \hsize=<>pt}`
344///     and then instructing TeX to describe the contents of the box.
345///
346/// This function is the inverse of TeX.2021.173 and onwards
347/// (part 12 of TeX: displaying boxes).
348pub fn build_vertical_lists(
349    tex_engine: &mut dyn TexEngine,
350    auxiliary_files: &HashMap<PathBuf, Vec<u8>>,
351    preamble: &str,
352    widths: &[common::Scaled],
353    contents: &mut dyn Iterator<Item = &String>,
354) -> (HashMap<String, common::FontId>, Vec<ds::VBox>) {
355    let last_width = *widths.last().expect("widths is non-empty");
356    let box_template = if widths.len() == 1 {
357        format!(r"\vbox{{\noindent \hsize={} #1}}", last_width)
358    } else {
359        let parshape_specs: String = widths.iter().map(|w| format!("0pt {w} ")).collect();
360        format!(
361            r"\vbox{{\noindent \hsize={} \parshape {} {}#1}}",
362            last_width,
363            widths.len(),
364            parshape_specs,
365        )
366    };
367    let macro_calls: Vec<String> = contents.map(|s| format!(r#"\printBox{{{s}}}"#)).collect();
368    let tex_source_code = CONVERT_TEXT_TEMPLATE
369        .replace("<preamble>", preamble)
370        .replace("<box_template>", &box_template)
371        .replace("<print_calls>", &macro_calls.join("\n\n"));
372    let output = tex_engine.run(&tex_source_code, auxiliary_files);
373    let segments = extract_texcraft_segments(&output);
374    let mut fonts: HashMap<String, common::FontId> = Default::default();
375    let vlists = segments
376        .map(|s| parse_v_box(&mut TexOutputIter::new(s), &mut fonts).unwrap())
377        .collect();
378    (fonts, vlists)
379}
380
381struct TexOutputIter<'tex> {
382    s: &'tex str,
383    depth: usize,
384    line_number: usize,
385}
386
387impl<'tex> Iterator for TexOutputIter<'tex> {
388    type Item = (usize, &'tex str);
389
390    fn next(&mut self) -> Option<Self::Item> {
391        let (line_number, line, n) = self.peek_impl()?;
392        self.s = &self.s[n..];
393        self.line_number += 1;
394        Some((line_number, line))
395    }
396}
397
398impl<'tex> TexOutputIter<'tex> {
399    fn new(mut s: &'tex str) -> Self {
400        let mut line_number = 1_usize;
401        loop {
402            let line = s
403                .split_inclusive('\n')
404                .next()
405                .expect("still searching for start of output");
406            s = &s[line.len()..];
407            line_number += 1;
408            if !line.starts_with(r"> \box0=") {
409                continue;
410            }
411            return Self {
412                s,
413                depth: 0,
414                line_number,
415            };
416        }
417    }
418    fn inner(&self) -> Self {
419        Self {
420            s: self.s,
421            depth: self.depth + 1,
422            line_number: self.line_number,
423        }
424    }
425    fn peek(&mut self) -> Option<(usize, &'tex str)> {
426        let (line_number, line, _) = self.peek_impl()?;
427        Some((line_number, line))
428    }
429    fn peek_impl(&mut self) -> Option<(usize, &'tex str, usize)> {
430        loop {
431            let line = self.s.split_inclusive('\n').next()?;
432            let n = line.len();
433            let line = line.trim_end();
434            if line.is_empty() {
435                self.s = "";
436                return None;
437            }
438            let line_depth = line.chars().take_while(|&c| c == '.').count();
439            use std::cmp::Ordering::*;
440            match line_depth.cmp(&self.depth) {
441                Less => {
442                    self.s = "";
443                    return None;
444                }
445                Equal => {
446                    return Some((self.line_number, &line[line_depth..], n));
447                }
448                Greater => {
449                    self.s = &self.s[n..];
450                    self.line_number += 1;
451                }
452            }
453        }
454    }
455}
456
457/// Extract the raw content between each pair of "Texcraft: begin" / "Texcraft: end" markers.
458///
459/// Yields one `&str` slice per begin/end pair, borrowing directly from `output`.
460fn extract_texcraft_segments(mut s: &str) -> impl Iterator<Item = &str> {
461    std::iter::from_fn(move || {
462        // Scan forward to the next "Texcraft: begin" line and record the segment start.
463        loop {
464            let line = s.split_inclusive('\n').next()?;
465            s = &s[line.len()..];
466            if line.starts_with("Texcraft: begin") {
467                break;
468            }
469        }
470        let next = s;
471        let mut next_len = 0_usize;
472        // Scan forward to the next "Texcraft: end" line and record the segment end.
473        loop {
474            let line = s.split_inclusive('\n').next()?;
475            s = &s[line.len()..];
476            next_len += line.len();
477            if line.starts_with("Texcraft: end") {
478                return Some(&next[0..next_len]);
479            }
480        }
481    })
482}
483
484/// Kind of error when parsing TeX logs.
485#[derive(Debug, PartialEq)]
486pub enum ErrorKind {
487    /// The iterator was empty when the start of an hlist was expected.
488    EmptyHlist,
489    /// The first line did not start with `\hbox(` as required.
490    MissingHboxPrefix,
491    /// The hbox dimension spec had no `+` separating height from depth.
492    MissingHboxHeightDepthSeparator,
493    /// The hbox dimension spec had no `)x` separating depth from width.
494    MissingHboxDepthWidthSeparator,
495    /// A `\kern` item had no width value.
496    KernMissingWidth,
497    /// A `\penalty` value could not be parsed as an integer.
498    InvalidPenaltyValue,
499    /// A `\rule` item had no `x` separating the height/depth from the width.
500    RuleMissingWidthSeparator,
501    /// A `\discretionary` item had a word other than `replacing` where `replacing` was expected.
502    DiscretionaryExpectedReplacingKeyword,
503    /// A `\discretionary replacing` item had no replacement count.
504    DiscretionaryMissingReplaceCount,
505    /// A `\discretionary replacing N` item had a count that could not be parsed as an integer.
506    DiscretionaryInvalidReplaceCount,
507    /// A font command was not followed by a character.
508    MissingCharAfterFont,
509    /// A ligature item had no original chars word after `(ligature`.
510    LigatureMissingOriginalChars,
511    /// The original chars of a ligature did not end with `)`.
512    LigatureMissingClosingParen,
513    /// The iterator was empty when the start of a vlist was expected.
514    EmptyVlist,
515    /// The first line did not start with `\vbox(` as required.
516    MissingVboxPrefix,
517    /// The vbox dimension spec had no `+` separating height from depth.
518    MissingVboxHeightDepthSeparator,
519    /// The vbox dimension spec had no `)x` separating depth from width.
520    MissingVboxDepthWidthSeparator,
521    /// A vlist contained a keyword that is not yet handled.
522    UnknownVlistKeyword,
523}
524
525impl std::fmt::Display for ErrorKind {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        match self {
528            ErrorKind::EmptyHlist => write!(f, "iterator was empty when an hlist was expected"),
529            ErrorKind::MissingHboxPrefix => write!(f, r"first line did not start with \hbox("),
530            ErrorKind::MissingHboxHeightDepthSeparator => {
531                write!(
532                    f,
533                    r"hbox dimension spec missing '+' between height and depth"
534                )
535            }
536            ErrorKind::MissingHboxDepthWidthSeparator => {
537                write!(
538                    f,
539                    r"hbox dimension spec missing ')x' between depth and width"
540                )
541            }
542            ErrorKind::KernMissingWidth => write!(f, r"\kern item had no width value"),
543            ErrorKind::InvalidPenaltyValue => {
544                write!(f, r"\penalty value could not be parsed as an integer")
545            }
546            ErrorKind::RuleMissingWidthSeparator => {
547                write!(
548                    f,
549                    r"\rule item had no 'x' separating height/depth from width"
550                )
551            }
552            ErrorKind::DiscretionaryExpectedReplacingKeyword => {
553                write!(
554                    f,
555                    r"\discretionary item had unexpected word where 'replacing' was expected"
556                )
557            }
558            ErrorKind::DiscretionaryMissingReplaceCount => {
559                write!(f, r"\discretionary replacing item had no replacement count")
560            }
561            ErrorKind::DiscretionaryInvalidReplaceCount => {
562                write!(
563                    f,
564                    r"\discretionary replacing count could not be parsed as an integer"
565                )
566            }
567            ErrorKind::MissingCharAfterFont => {
568                write!(f, "font command was not followed by a character")
569            }
570            ErrorKind::LigatureMissingOriginalChars => {
571                write!(f, "ligature item had no original chars after '(ligature'")
572            }
573            ErrorKind::LigatureMissingClosingParen => {
574                write!(f, "ligature original chars did not end with ')'")
575            }
576            ErrorKind::EmptyVlist => write!(f, "iterator was empty when a vlist was expected"),
577            ErrorKind::MissingVboxPrefix => write!(f, r"first line did not start with \vbox("),
578            ErrorKind::MissingVboxHeightDepthSeparator => {
579                write!(
580                    f,
581                    r"vbox dimension spec missing '+' between height and depth"
582                )
583            }
584            ErrorKind::MissingVboxDepthWidthSeparator => {
585                write!(
586                    f,
587                    r"vbox dimension spec missing ')x' between depth and width"
588                )
589            }
590            ErrorKind::UnknownVlistKeyword => write!(f, "vlist contained an unhandled keyword"),
591        }
592    }
593}
594
595impl std::error::Error for ErrorKind {}
596
597/// Error returned by internal functions that parse TeX logs
598/// These internal parsing functions will eventually be made public
599/// and so the errors were made public.
600#[derive(Debug, PartialEq)]
601pub struct Error {
602    pub kind: ErrorKind,
603    pub line_number: usize,
604}
605
606impl std::fmt::Display for Error {
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        write!(f, "line {}: {}", self.line_number, self.kind)
609    }
610}
611
612impl std::error::Error for Error {}
613
614/// Returns the ID to assign to the next font discovered in the TeX output.
615///
616/// Font IDs start at 1 and are assigned in the order the fonts are encountered.
617fn next_font_id(fonts: &HashMap<String, common::FontId>) -> common::FontId {
618    common::FontId(
619        (fonts.len() + 1)
620            .try_into()
621            .expect("no more than 2^32-1 fonts"),
622    )
623}
624
625fn parse_disc_elem(
626    line: &str,
627    line_number: usize,
628    fonts: &mut HashMap<String, common::FontId>,
629) -> Result<ds::DiscretionaryElem, Error> {
630    let (keyword, tail) = keyword_and_tail(line).unwrap();
631    match keyword {
632        "kern" => {
633            let mut words = tail.split_ascii_whitespace();
634            let width = parse_scaled(words.next().ok_or(Error {
635                kind: ErrorKind::KernMissingWidth,
636                line_number,
637            })?);
638            Ok(ds::DiscretionaryElem::Kern(ds::Kern {
639                kind: ds::KernKind::Normal,
640                width,
641            }))
642        }
643        font_name => {
644            use std::collections::hash_map::Entry;
645            let next_font = next_font_id(fonts);
646            let font = match fonts.entry(font_name.to_string()) {
647                Entry::Occupied(e) => *e.get(),
648                Entry::Vacant(e) => {
649                    e.insert(next_font);
650                    next_font
651                }
652            };
653            Ok(match parse_char(tail, line_number)? {
654                ParsedChar::Char(char) => ds::Char { char, font }.into(),
655                ParsedChar::Lig(char, og_chars) => ds::Ligature {
656                    includes_left_boundary: false,
657                    includes_right_boundary: false,
658                    char,
659                    font,
660                    original_chars: og_chars.into(),
661                }
662                .into(),
663            })
664        }
665    }
666}
667
668/// Parse a single raw hlist segment (as produced by [`extract_hlist_segments`]) into an hlist.
669///
670/// The `fonts` map is updated in place as new fonts are encountered.
671fn parse_h_box(
672    iter: &mut TexOutputIter,
673    fonts: &mut HashMap<String, common::FontId>,
674) -> Result<ds::HBox, Error> {
675    let mut h_box = {
676        let line_number_hint = iter.line_number;
677        let (line_number, line) = iter.next().ok_or(Error {
678            kind: ErrorKind::EmptyHlist,
679            line_number: line_number_hint,
680        })?;
681        let s = line.strip_prefix(r"\hbox(").ok_or(Error {
682            kind: ErrorKind::MissingHboxPrefix,
683            line_number,
684        })?;
685        let i = s.find('+').ok_or(Error {
686            kind: ErrorKind::MissingHboxHeightDepthSeparator,
687            line_number,
688        })?;
689        let height = parse_scaled(&s[..i]);
690        let s = &s[i + 1..];
691        let i = s.find(")x").ok_or(Error {
692            kind: ErrorKind::MissingHboxDepthWidthSeparator,
693            line_number,
694        })?;
695        let depth = parse_scaled(&s[..i]);
696        let rest = &s[i + 2..];
697        let (width_str, glue_set_str) = if let Some(j) = rest.find(", glue set ") {
698            (&rest[..j], Some(&rest[j + 11..]))
699        } else {
700            (rest, None)
701        };
702        let width = parse_scaled(width_str);
703        let (glue_ratio, glue_order) = glue_set_str
704            .map(parse_glue_set)
705            .unwrap_or((ds::GlueRatio::default(), common::GlueOrder::Normal));
706        ds::HBox {
707            height,
708            width,
709            depth,
710            glue_ratio,
711            glue_order,
712            ..Default::default()
713        }
714    };
715    let mut iter = iter.inner();
716    h_box.list = parse_h_box_list(&mut iter, fonts)?;
717    Ok(h_box)
718}
719
720fn parse_h_box_list(
721    iter: &mut TexOutputIter,
722    fonts: &mut HashMap<String, common::FontId>,
723) -> Result<Vec<ds::Horizontal>, Error> {
724    let mut list = vec![];
725    while let Some((line_number, line)) = iter.peek() {
726        let Some((keyword, tail)) = keyword_and_tail(line) else {
727            break;
728        };
729        let mut consume_line = true;
730        let elem: ds::Horizontal = match keyword {
731            "glue" => ds::Glue {
732                kind: ds::GlueKind::Normal,
733                value: parse_glue_value(tail),
734            }
735            .into(),
736            "kern" => {
737                let mut words = tail.split_ascii_whitespace();
738                let width = parse_scaled(words.next().ok_or(Error {
739                    kind: ErrorKind::KernMissingWidth,
740                    line_number,
741                })?);
742                ds::Kern {
743                    kind: ds::KernKind::Normal,
744                    width,
745                }
746                .into()
747            }
748            "penalty" => {
749                let value: i32 = tail.trim().parse().map_err(|_| Error {
750                    kind: ErrorKind::InvalidPenaltyValue,
751                    line_number,
752                })?;
753                ds::Penalty(value).into()
754            }
755            "rule" => {
756                let i = tail.find('x').ok_or(Error {
757                    kind: ErrorKind::RuleMissingWidthSeparator,
758                    line_number,
759                })?;
760                ds::Rule {
761                    width: parse_scaled(&tail[i + 1..]),
762                    ..Default::default()
763                }
764                .into()
765            }
766            "discretionary" => {
767                // Inverse of TeX.2021.195
768                let mut words = tail.split_ascii_whitespace();
769                let replace_count = match words.next() {
770                    None => 0,
771                    Some(replacing) => {
772                        if replacing != "replacing" {
773                            return Err(Error {
774                                kind: ErrorKind::DiscretionaryExpectedReplacingKeyword,
775                                line_number,
776                            });
777                        }
778                        let count_str = words.next().ok_or(Error {
779                            kind: ErrorKind::DiscretionaryMissingReplaceCount,
780                            line_number,
781                        })?;
782                        count_str.parse().map_err(|_| Error {
783                            kind: ErrorKind::DiscretionaryInvalidReplaceCount,
784                            line_number,
785                        })?
786                    }
787                };
788                // Consume the \discretionary line now so iter.peek() below sees sub-items.
789                iter.next();
790                consume_line = false;
791                // Pre-break items are at depth+1 (normal dot-prefixed sub-lines).
792                let mut pre_break = vec![];
793                {
794                    let mut pre_iter = iter.inner();
795                    while let Some((ln, line)) = pre_iter.peek() {
796                        pre_break.push(parse_disc_elem(line, ln, fonts)?);
797                        pre_iter.next();
798                    }
799                }
800                // Post-break items are at the current depth but with content starting with `|`.
801                // iter.peek() lazily skips the depth+1 pre-break lines via peek_impl's Greater branch.
802                let mut post_break = vec![];
803                while let Some((ln, line)) = iter.peek() {
804                    let Some(post_line) = line.strip_prefix('|') else {
805                        break;
806                    };
807                    iter.next();
808                    post_break.push(parse_disc_elem(post_line, ln, fonts)?);
809                }
810                ds::Discretionary {
811                    pre_break,
812                    post_break,
813                    replace_count,
814                }
815                .into()
816            }
817            "hbox" => {
818                consume_line = false;
819                parse_h_box(iter, fonts)?.into()
820            }
821            "vbox" => {
822                consume_line = false;
823                parse_v_box(iter, fonts)?.into()
824            }
825            font_name => {
826                use std::collections::hash_map::Entry;
827                let next_font = next_font_id(fonts);
828                let font = match fonts.entry(font_name.to_string()) {
829                    Entry::Occupied(occupied_entry) => *occupied_entry.get(),
830                    Entry::Vacant(vacant_entry) => {
831                        vacant_entry.insert(next_font);
832                        next_font
833                    }
834                };
835                match parse_char(tail, line_number)? {
836                    ParsedChar::Char(char) => ds::Char { char, font }.into(),
837                    ParsedChar::Lig(char, og_chars) => ds::Ligature {
838                        includes_left_boundary: false,
839                        includes_right_boundary: false,
840                        char,
841                        font,
842                        original_chars: og_chars.into(),
843                    }
844                    .into(),
845                }
846            }
847        };
848        list.push(elem);
849        if consume_line {
850            iter.next();
851        }
852    }
853    Ok(list)
854}
855
856/// Parse a single raw vlist segment into a vlist.
857fn parse_v_box(
858    iter: &mut TexOutputIter,
859    fonts: &mut HashMap<String, common::FontId>,
860) -> Result<ds::VBox, Error> {
861    let mut vlist = {
862        let line_number_hint = iter.line_number;
863        let (line_number, line) = iter.next().ok_or(Error {
864            kind: ErrorKind::EmptyVlist,
865            line_number: line_number_hint,
866        })?;
867        let s = line.strip_prefix(r"\vbox(").ok_or(Error {
868            kind: ErrorKind::MissingVboxPrefix,
869            line_number,
870        })?;
871        let i = s.find('+').ok_or(Error {
872            kind: ErrorKind::MissingVboxHeightDepthSeparator,
873            line_number,
874        })?;
875        let _height = parse_scaled(&s[..i]);
876        let s = &s[i + 1..];
877        let i = s.find(")x").ok_or(Error {
878            kind: ErrorKind::MissingVboxDepthWidthSeparator,
879            line_number,
880        })?;
881        let _depth = parse_scaled(&s[..i]);
882        let _width = parse_scaled(&s[i + 2..]);
883        // TODO: use the real heights when Boxworks has these populated too.
884        let (width, height, depth) = (
885            common::Scaled::ZERO,
886            common::Scaled::ZERO,
887            common::Scaled::ZERO,
888        );
889        ds::VBox {
890            height,
891            width,
892            depth,
893            shift_amount: common::Scaled::ZERO,
894            list: vec![],
895            glue_ratio: Default::default(),
896            glue_order: common::GlueOrder::Normal,
897        }
898    };
899    let mut iter = iter.inner();
900    vlist.list = parse_v_box_list(&mut iter, fonts)?;
901    Ok(vlist)
902}
903
904fn parse_v_box_list(
905    iter: &mut TexOutputIter,
906    fonts: &mut HashMap<String, common::FontId>,
907) -> Result<Vec<ds::Vertical>, Error> {
908    let mut list = vec![];
909    while let Some((line_number, line)) = iter.peek() {
910        let Some((keyword, tail)) = keyword_and_tail(line) else {
911            break;
912        };
913        let mut consume_line = true;
914        let elem: ds::Vertical = match keyword {
915            "hbox" => {
916                consume_line = false;
917                parse_h_box(iter, fonts)?.into()
918            }
919            "penalty" => {
920                let value: i32 = tail.trim().parse().map_err(|_| Error {
921                    kind: ErrorKind::InvalidPenaltyValue,
922                    line_number,
923                })?;
924                ds::Penalty(value).into()
925            }
926            "glue" => ds::Vertical::Glue(ds::Glue {
927                kind: ds::GlueKind::Normal,
928                value: parse_glue_value(tail),
929            }),
930            _ => {
931                return Err(Error {
932                    kind: ErrorKind::UnknownVlistKeyword,
933                    line_number,
934                })
935            }
936        };
937        list.push(elem);
938        if consume_line {
939            iter.next();
940        }
941    }
942    Ok(list)
943}
944
945fn keyword_and_tail(s: &str) -> Option<(&str, &str)> {
946    let mut c = s.chars();
947    if c.next() != Some('\\') {
948        return None;
949    }
950    let mut keyword_len = 0_usize;
951    for next in c {
952        if next.is_alphabetic() {
953            keyword_len += next.len_utf8();
954        } else {
955            break;
956        }
957    }
958    Some((&s[1..1 + keyword_len], s[1 + keyword_len..].trim()))
959}
960
961/// Parse the glue value from the text following `\glue` in TeX's box display.
962///
963/// `spec` may start with a parenthesised name (named glue, e.g. `(\rightskip) 0.0`)
964/// or directly with a space followed by the width (e.g. ` 3.33333 plus 1.66666 minus 1.11111`).
965fn parse_glue_value(spec: &str) -> common::Glue {
966    let spec = if spec.starts_with('(') {
967        let close = spec.find(')').expect("named glue has ')'");
968        spec[close + 1..].trim_start()
969    } else {
970        spec.trim_start()
971    };
972    let mut words = spec.split_ascii_whitespace();
973    let width = parse_scaled(words.next().expect("glue has a width"));
974    let (stretch, stretch_order) = if words.next() == Some("plus") {
975        parse_glue_amount(words.next().expect("glue has stretch after plus"))
976    } else {
977        (common::Scaled::ZERO, common::GlueOrder::Normal)
978    };
979    let (shrink, shrink_order) = if words.next() == Some("minus") {
980        parse_glue_amount(words.next().expect("glue has shrink after minus"))
981    } else {
982        (common::Scaled::ZERO, common::GlueOrder::Normal)
983    };
984    common::Glue {
985        width,
986        stretch,
987        stretch_order,
988        shrink,
989        shrink_order,
990    }
991}
992
993/// Parse a glue component like `"1.66666"`, `"1.0fil"`, `"0.0fill"`.
994fn parse_glue_amount(s: &str) -> (common::Scaled, common::GlueOrder) {
995    if let Some(s) = s.strip_suffix("filll") {
996        (parse_scaled(s), common::GlueOrder::Filll)
997    } else if let Some(s) = s.strip_suffix("fill") {
998        (parse_scaled(s), common::GlueOrder::Fill)
999    } else if let Some(s) = s.strip_suffix("fil") {
1000        (parse_scaled(s), common::GlueOrder::Fil)
1001    } else {
1002        (parse_scaled(s), common::GlueOrder::Normal)
1003    }
1004}
1005
1006/// Parse the `"N[order]"` text that follows `", glue set "` on an hbox line.
1007fn parse_glue_set(s: &str) -> (ds::GlueRatio, common::GlueOrder) {
1008    let (neg, s) = if let Some(s) = s.strip_prefix("- ") {
1009        (true, s)
1010    } else {
1011        (false, s)
1012    };
1013    let (s, order) = if let Some(s) = s.strip_suffix("filll") {
1014        (s, common::GlueOrder::Filll)
1015    } else if let Some(s) = s.strip_suffix("fill") {
1016        (s, common::GlueOrder::Fill)
1017    } else if let Some(s) = s.strip_suffix("fil") {
1018        (s, common::GlueOrder::Fil)
1019    } else {
1020        (s, common::GlueOrder::Normal)
1021    };
1022    let mut ratio = ds::GlueRatio::from_float_str(s).expect("glue set ratio is a float");
1023    if neg {
1024        ratio.num.0 *= -1;
1025    }
1026    (ratio, order)
1027}
1028
1029enum ParsedChar<'a> {
1030    Char(char),
1031    Lig(char, &'a str),
1032}
1033
1034fn parse_char<'a>(s: &'a str, line_number: usize) -> Result<ParsedChar<'a>, Error> {
1035    let mut words = s.split_ascii_whitespace();
1036    let s = words.next().ok_or(Error {
1037        kind: ErrorKind::MissingCharAfterFont,
1038        line_number,
1039    })?;
1040    let mut cs = s.chars();
1041    let c = match cs.next().expect("char has one character") {
1042        '^' => {
1043            assert_eq!(cs.next(), Some('^'));
1044            let raw_c = cs.next().expect("char of the form ^^X") as u32;
1045            if let Some(raw_c) = raw_c.checked_sub(64) {
1046                raw_c
1047            } else {
1048                raw_c + 64
1049            }
1050            .try_into()
1051            .expect("TeX describes a valid character")
1052        }
1053        c => c,
1054    };
1055    Ok(if words.next() == Some("(ligature") {
1056        let og_chars = words.next().ok_or(Error {
1057            kind: ErrorKind::LigatureMissingOriginalChars,
1058            line_number,
1059        })?;
1060        let og_chars = og_chars.strip_suffix(')').ok_or(Error {
1061            kind: ErrorKind::LigatureMissingClosingParen,
1062            line_number,
1063        })?;
1064        ParsedChar::Lig(c, og_chars)
1065    } else {
1066        ParsedChar::Char(c)
1067    })
1068}
1069
1070fn parse_scaled(s: &str) -> common::Scaled {
1071    common::Scaled::parse_no_units(s).unwrap_or_else(|_| {
1072        eprintln!(
1073            "scaled number '{s}pt' is too big to parse; replacing with the largest scaled value {}",
1074            common::Scaled::MAX_DIMEN
1075        );
1076        if s.starts_with('-') {
1077            -common::Scaled::MAX_DIMEN
1078        } else {
1079            common::Scaled::MAX_DIMEN
1080        }
1081    })
1082}
1083
1084const CONVERT_TEXT_TEMPLATE: &str = r"
1085
1086% User provided preamble.
1087<preamble>
1088
1089% After showing a box, TeX stops and waits for user input.
1090% The following command suppresses that behavior.
1091\nonstopmode
1092
1093% Output the box description to the terminal, from which we'll read it.
1094\tracingonline=1
1095
1096% Output up to 1 million nodes.
1097\showboxbreadth=1000000
1098% Output up to 100 nested boxes.
1099\showboxdepth=100
1100
1101% Prints the contents on its own line in the terminal.
1102\def\fullLineMessage#1{
1103    {
1104        \newlinechar=`@
1105        \message{@#1@}
1106    }
1107}
1108
1109\def\printBox#1{
1110    % Put the content we want to see in box 0.
1111    \setbox0=<box_template>
1112    % Add a start marker so we know where to begin in the log
1113    \fullLineMessage{Texcraft: begin}
1114    % Show the box!
1115    \showbox0
1116
1117    % Add a start marker so we know where to end in the log
1118    \fullLineMessage{Texcraft: end}
1119}
1120
1121<print_calls>
1122
1123We add some text at the end.
1124
1125\end
1126";
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::*;
1131    use pretty_assertions::assert_eq;
1132
1133    fn parse_hbox_lang(source: &str) -> ds::HBox {
1134        let mut list = crate::lang::parse_horizontal_list(source).unwrap();
1135        assert_eq!(list.len(), 1);
1136        match list.remove(0) {
1137            ds::Horizontal::HBox(hbox) => hbox,
1138            other => panic!("expected hbox, got {other:?}"),
1139        }
1140    }
1141
1142    fn parse_vbox_lang(source: &str) -> ds::VBox {
1143        let mut list = crate::lang::parse_horizontal_list(source).unwrap();
1144        assert_eq!(list.len(), 1);
1145        match list.remove(0) {
1146            ds::Horizontal::VBox(mut vbox) => {
1147                // TODO: when vpack is implemented, remove this.
1148                vbox.width = common::Scaled::ZERO;
1149                vbox.height = common::Scaled::ZERO;
1150                vbox.depth = common::Scaled::ZERO;
1151                vbox.shift_amount = common::Scaled::ZERO;
1152                vbox
1153            }
1154            other => panic!("expected vbox, got {other:?}"),
1155        }
1156    }
1157
1158    struct MockTexEngine(String);
1159
1160    impl TexEngine for MockTexEngine {
1161        fn run(&mut self, _: &str, _: &HashMap<PathBuf, Vec<u8>>) -> String {
1162            self.0.clone()
1163        }
1164    }
1165
1166    #[test]
1167    fn test_build_horizontal_lists() {
1168        let log = include_str!("hbox_template_1.log");
1169
1170        let mut tex_engine = MockTexEngine(log.to_string());
1171        let (got_fonts, got_list) = build_horizontal_lists(
1172            &mut tex_engine,
1173            &Default::default(),
1174            &"",
1175            &mut vec!["".to_string()].iter(),
1176            false,
1177        );
1178
1179        let want_list = parse_hbox_lang(
1180            r#"
1181            hbox(
1182                height=6.94444pt,
1183                width=56.66678pt,
1184                content=[
1185                    chars("Min")
1186                    kern(-0.27779pt)
1187                    chars("t")
1188                    glue(3.33333pt, 1.66666pt, 1.11111pt)
1189                    chars("and")
1190                    glue(3.33333pt, 1.66666pt, 1.11111pt)
1191                    chars("me")
1192                ]
1193            )
1194        "#,
1195        );
1196        let want_fonts = {
1197            let mut m = HashMap::new();
1198            m.insert("customFont".to_string(), common::FontId::ONE);
1199            m
1200        };
1201
1202        assert_eq!(got_list, vec![want_list]);
1203        assert_eq!(got_fonts, want_fonts);
1204    }
1205
1206    #[test]
1207    fn test_discretionary_pre_and_post_break() {
1208        let input = r"> \box0=x
1209\hbox(6.94444+0.0)x10.0
1210.\discretionary replacing 3
1211..\tenrm d
1212..\tenrm ^^L (ligature fi)
1213..\tenrm f
1214..\tenrm -
1215.|\tenrm ^^L (ligature fi)
1216.\tenrm ^^N (ligature ffi)
1217";
1218        let mut fonts = Default::default();
1219        let got = parse_h_box(&mut TexOutputIter::new(input), &mut fonts).unwrap();
1220        let want = parse_hbox_lang(
1221            r#"hbox(
1222                height=6.94444pt,
1223                width=10.0pt,
1224                content=[
1225                    disc(
1226                        pre_break=[
1227                            chars("d")
1228                            lig("\u{c}", "fi")
1229                            chars("f-")
1230                        ],
1231                        post_break=[
1232                            lig("\u{c}", "fi")
1233                        ],
1234                        replace_count=3,
1235                    )
1236                    lig("\u{e}", "ffi")
1237                ]
1238            )"#,
1239        );
1240        assert_eq!(got, want);
1241    }
1242
1243    #[test]
1244    fn test_build_vertical_lists() {
1245        let log = r#"
1246This is TeX, Version 3.141592653 (TeX Live 2024) (preloaded format=tex)
1247(./test.tex
1248
1249Texcraft: begin
1250> \box0=
1251\vbox(18.94444+0.0)x41.0
1252.\hbox(6.94444+0.0)x41.0, glue set 0.26662
1253..\tenrm M
1254..\tenrm i
1255..\tenrm n
1256..\kern-0.27779
1257..\tenrm t
1258..\glue 3.33333 plus 1.66666 minus 1.11111
1259..\tenrm a
1260..\tenrm n
1261..\tenrm d
1262..\glue(\rightskip) 0.0
1263.\penalty 300
1264.\glue(\baselineskip) 7.69446
1265.\hbox(4.30554+0.0)x41.0, glue set 28.2222fil
1266..\tenrm m
1267..\tenrm e
1268..\penalty 10000
1269..\glue(\parfillskip) 0.0 plus 1.0fil
1270..\glue(\rightskip) 0.0
1271
1272! OK.
1273\printBox ...Message {Texcraft: begin} \showbox 0 
1274                                                  \par \fullLineMessage {Tex...
1275l.31 \printBox{Mint and me}
1276                           
1277
1278Texcraft: end
1279 )
1280(see the transcript file for additional information)
1281No pages of output.
1282Transcript written on test.log.
1283"#;
1284        let mut tex_engine = MockTexEngine(log.to_string());
1285        let (got_fonts, got_list) = build_vertical_lists(
1286            &mut tex_engine,
1287            &Default::default(),
1288            &"",
1289            &[common::Scaled::ONE * 41],
1290            &mut vec!["".to_string()].iter(),
1291        );
1292
1293        let want_list = parse_vbox_lang(
1294            r#"
1295            vbox(
1296                height=18.94444pt,
1297                width=41.0pt,
1298                content=[
1299                    hbox(
1300                        height=6.94444pt,
1301                        width=41.0pt,
1302                        glue_ratio="0.26662",
1303                        content=[
1304                            chars("Min")
1305                            kern(-0.27779pt)
1306                            chars("t")
1307                            glue(3.33333pt, 1.66666pt, 1.11111pt)
1308                            chars("and")
1309                            glue()
1310                        ]
1311                    )
1312                    penalty(300)
1313                    glue(7.69446pt)
1314                    hbox(
1315                        height=4.30554pt,
1316                        width=41.0pt,
1317                        glue_ratio="28.2222",
1318                        glue_order="fil",
1319                        content=[
1320                            chars("me")
1321                            penalty(10000)
1322                            glue(0.0pt, 1.0fil, 0.0pt)
1323                            glue()
1324                        ]
1325                    )
1326                ]
1327            )
1328        "#,
1329        );
1330        let want_fonts = {
1331            let mut m = HashMap::new();
1332            m.insert("tenrm".to_string(), common::FontId::ONE);
1333            m
1334        };
1335
1336        assert_eq!(got_list, vec![want_list]);
1337        assert_eq!(got_fonts, want_fonts);
1338    }
1339    #[test]
1340    fn test_build_vertical_lists_2() {
1341        let log = r#"
1342This is TeX, Version 3.141592653 (TeX Live 2024) (preloaded format=tex)
1343(./test.tex
1344
1345Texcraft: begin
1346> \box0=
1347\vbox(6.83331+0.0)x41.0
1348.\hbox(6.83331+0.0)x41.0
1349..\tenrm A
1350..\hbox(6.83331+0.0)x48.08336
1351...\tenrm B
1352...\vbox(6.83331+0.0)x41.0
1353....\hbox(6.83331+0.0)x41.0, glue set 6.13887fil
1354.....\hbox(0.0+0.0)x20.0
1355.....\tenrm C
1356.....\hbox(6.83331+0.0)x7.6389
1357......\tenrm D
1358.....\penalty 10000
1359.....\glue(\parfillskip) 0.0 plus 1.0fil
1360.....\glue(\rightskip) 0.0
1361..\penalty 10000
1362..\glue(\parfillskip) 0.0 plus 1.0fil
1363..\glue(\rightskip) 0.0
1364..\rule(*+*)x5.0
1365
1366! OK.
1367\printBox ...Message {Texcraft: begin} \showbox 0 
1368                                                  \par \fullLineMessage {Tex...
1369l.31 \printBox{Mint and me}
1370                           
1371
1372Texcraft: end
1373 )
1374(see the transcript file for additional information)
1375No pages of output.
1376Transcript written on test.log.
1377"#;
1378
1379        let mut tex_engine = MockTexEngine(log.to_string());
1380        let (got_fonts, got_list) = build_vertical_lists(
1381            &mut tex_engine,
1382            &Default::default(),
1383            &"",
1384            &[common::Scaled::ONE * 41],
1385            &mut vec!["".to_string()].iter(),
1386        );
1387
1388        let want_list = parse_vbox_lang(
1389            r#"
1390            vbox(
1391                height=6.83331pt,
1392                width=41.0pt,
1393                content=[
1394                    hbox(
1395                        height=6.83331pt,
1396                        width=41.0pt,
1397                        content=[
1398                            chars("A")
1399                            hbox(
1400                                height=6.83331pt,
1401                                width=48.08336pt,
1402                                content=[
1403                                    chars("B")
1404                                    vbox(
1405                                        # todo
1406                                        # height=6.83331pt,
1407                                        # width=41.0pt,
1408                                        content=[
1409                                            hbox(
1410                                                height=6.83331pt,
1411                                                width=41.0pt,
1412                                                glue_ratio="6.13887",
1413                                                glue_order="fil",
1414                                                content=[
1415                                                    hbox(width=20.0pt)
1416                                                    chars("C")
1417                                                    hbox(
1418                                                        height=6.83331pt,
1419                                                        width=7.6389pt,
1420                                                        content=[chars("D")]
1421                                                    )
1422                                                    penalty(10000)
1423                                                    glue(0.0pt, 1.0fil, 0.0pt)
1424                                                    glue()
1425                                                ]
1426                                            )
1427                                        ]
1428                                    )
1429                                ]
1430                            )
1431                            penalty(10000)
1432                            glue(0.0pt, 1.0fil, 0.0pt)
1433                            glue()
1434                            rule(height="running", width=5.0pt, depth="running")
1435                        ]
1436                    )
1437                ]
1438            )
1439        "#,
1440        );
1441        let want_fonts = {
1442            let mut m = HashMap::new();
1443            m.insert("tenrm".to_string(), common::FontId::ONE);
1444            m
1445        };
1446
1447        assert_eq!(got_fonts, want_fonts);
1448        assert_eq!(got_list, vec![want_list]);
1449    }
1450
1451    macro_rules! test_parse_hlist_error {
1452        ($(($name:ident, $input:expr, $expected:expr)),* $(,)?) => [$(
1453            #[test]
1454            fn $name() {
1455                let err = parse_h_box(&mut TexOutputIter::new($input), &mut Default::default())
1456                    .unwrap_err();
1457                assert_eq!(err, $expected);
1458            }
1459        )*];
1460    }
1461
1462    test_parse_hlist_error![
1463        (
1464            test_empty_hlist,
1465            r"> \box0=
1466",
1467            Error {
1468                kind: ErrorKind::EmptyHlist,
1469                line_number: 2
1470            }
1471        ),
1472        (
1473            test_missing_hbox_prefix,
1474            r"> \box0=
1475\vbox(6.0+0.0)x10.0
1476",
1477            Error {
1478                kind: ErrorKind::MissingHboxPrefix,
1479                line_number: 2
1480            }
1481        ),
1482        (
1483            test_missing_height_depth_separator,
1484            r"> \box0=
1485\hbox(6.94444 no plus here)x10.0
1486",
1487            Error {
1488                kind: ErrorKind::MissingHboxHeightDepthSeparator,
1489                line_number: 2
1490            }
1491        ),
1492        (
1493            test_missing_depth_width_separator,
1494            r"> \box0=
1495\hbox(6.94444+0.0 no depth width)
1496",
1497            Error {
1498                kind: ErrorKind::MissingHboxDepthWidthSeparator,
1499                line_number: 2
1500            }
1501        ),
1502        (
1503            test_kern_missing_width,
1504            r"> \box0=
1505\hbox(6.94444+0.0)x10.0
1506.\kern
1507",
1508            Error {
1509                kind: ErrorKind::KernMissingWidth,
1510                line_number: 3
1511            }
1512        ),
1513        (
1514            test_invalid_penalty_value,
1515            r"> \box0=
1516\hbox(6.94444+0.0)x10.0
1517.\penalty abc
1518",
1519            Error {
1520                kind: ErrorKind::InvalidPenaltyValue,
1521                line_number: 3
1522            }
1523        ),
1524        (
1525            test_rule_missing_width_separator,
1526            r"> \box0=
1527\hbox(6.94444+0.0)x10.0
1528.\rule (*+*) 5.0
1529",
1530            Error {
1531                kind: ErrorKind::RuleMissingWidthSeparator,
1532                line_number: 3
1533            }
1534        ),
1535        (
1536            test_discretionary_expected_replacing_keyword,
1537            r"> \box0=
1538\hbox(6.94444+0.0)x10.0
1539.\discretionary wrong 3
1540",
1541            Error {
1542                kind: ErrorKind::DiscretionaryExpectedReplacingKeyword,
1543                line_number: 3
1544            }
1545        ),
1546        (
1547            test_discretionary_missing_replace_count,
1548            r"> \box0=
1549\hbox(6.94444+0.0)x10.0
1550.\discretionary replacing
1551",
1552            Error {
1553                kind: ErrorKind::DiscretionaryMissingReplaceCount,
1554                line_number: 3
1555            }
1556        ),
1557        (
1558            test_discretionary_invalid_replace_count,
1559            r"> \box0=
1560\hbox(6.94444+0.0)x10.0
1561.\discretionary replacing abc
1562",
1563            Error {
1564                kind: ErrorKind::DiscretionaryInvalidReplaceCount,
1565                line_number: 3
1566            }
1567        ),
1568        (
1569            test_missing_char_after_font,
1570            r"> \box0=
1571\hbox(6.94444+0.0)x10.0
1572.\tenrm
1573",
1574            Error {
1575                kind: ErrorKind::MissingCharAfterFont,
1576                line_number: 3
1577            }
1578        ),
1579        (
1580            test_ligature_missing_original_chars,
1581            r"> \box0=
1582\hbox(6.94444+0.0)x10.0
1583.\tenrm f (ligature
1584",
1585            Error {
1586                kind: ErrorKind::LigatureMissingOriginalChars,
1587                line_number: 3
1588            }
1589        ),
1590        (
1591            test_ligature_missing_closing_paren,
1592            r"> \box0=
1593\hbox(6.94444+0.0)x10.0
1594.\tenrm f (ligature fi
1595",
1596            Error {
1597                kind: ErrorKind::LigatureMissingClosingParen,
1598                line_number: 3
1599            }
1600        ),
1601    ];
1602
1603    macro_rules! test_parse_vlist_error {
1604        ($(($name:ident, $input:expr, $expected:expr)),* $(,)?) => [$(
1605            #[test]
1606            fn $name() {
1607                let err = parse_v_box(&mut TexOutputIter::new($input), &mut Default::default())
1608                    .unwrap_err();
1609                assert_eq!(err, $expected);
1610            }
1611        )*];
1612    }
1613
1614    test_parse_vlist_error![
1615        (
1616            test_empty_vlist,
1617            r"> \box0=
1618",
1619            Error {
1620                kind: ErrorKind::EmptyVlist,
1621                line_number: 2
1622            }
1623        ),
1624        (
1625            test_missing_vbox_prefix,
1626            r"> \box0=
1627\hbox(6.94444+0.0)x10.0
1628",
1629            Error {
1630                kind: ErrorKind::MissingVboxPrefix,
1631                line_number: 2
1632            }
1633        ),
1634        (
1635            test_missing_vbox_height_depth_separator,
1636            r"> \box0=
1637\vbox(6.94444 no plus here)x10.0
1638",
1639            Error {
1640                kind: ErrorKind::MissingVboxHeightDepthSeparator,
1641                line_number: 2
1642            }
1643        ),
1644        (
1645            test_missing_vbox_depth_width_separator,
1646            r"> \box0=
1647\vbox(6.94444+0.0 no depth width)
1648",
1649            Error {
1650                kind: ErrorKind::MissingVboxDepthWidthSeparator,
1651                line_number: 2
1652            }
1653        ),
1654        (
1655            test_vlist_invalid_penalty_value,
1656            r"> \box0=
1657\vbox(6.94444+0.0)x10.0
1658.\penalty abc
1659",
1660            Error {
1661                kind: ErrorKind::InvalidPenaltyValue,
1662                line_number: 3
1663            }
1664        ),
1665        (
1666            test_unknown_vlist_keyword,
1667            r"> \box0=
1668\vbox(6.94444+0.0)x10.0
1669.\unknown stuff
1670",
1671            Error {
1672                kind: ErrorKind::UnknownVlistKeyword,
1673                line_number: 3
1674            }
1675        ),
1676    ];
1677}