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