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