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