boxworks/
tex.rs

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