boxworks_text/
lib.rs

1//! # Boxworks text preprocessor
2//!
3//! This crate implements the logic that converts text (words and spaces)
4//! into horizontal list elements.
5//! It is implemented in the Chief Executive chapter in Knuth's
6//! TeX (starting in TeX.2021.1029).
7
8use boxworks::ds;
9use common::font;
10
11pub struct Params {
12    pub space_factor_codes: SpaceFactorCodes,
13    pub space_skip: common::Glue,
14    pub extra_space_skip: common::Glue,
15}
16
17impl Params {
18    /// Output the parameters in TeX format.
19    pub fn tex(&self) -> String {
20        let Params {
21            space_factor_codes,
22            space_skip,
23            extra_space_skip,
24        } = self;
25        _ = space_factor_codes;
26        format!(
27            r"
28            \spaceskip={space_skip}
29            \xspaceskip={extra_space_skip}
30        "
31        )
32    }
33}
34
35impl Default for Params {
36    fn default() -> Self {
37        Self::plain_tex_defaults()
38    }
39}
40
41impl Params {
42    pub fn plain_tex_defaults() -> Self {
43        Self {
44            space_factor_codes: SpaceFactorCodes::plain_tex_defaults(),
45            space_skip: common::Glue::ZERO,
46            extra_space_skip: common::Glue::ZERO,
47        }
48    }
49}
50
51pub struct TextPreprocessor {
52    current_font: font::Id,
53    space_factor: SpaceFactor,
54    pub params: Params,
55}
56
57impl TextPreprocessor {
58    pub fn new(params: Params) -> Self {
59        Self {
60            current_font: font::Id::NULL,
61            space_factor: Default::default(),
62            params,
63        }
64    }
65}
66
67pub struct SpaceFactorCodes(pub [i32; 256]);
68
69impl Default for SpaceFactorCodes {
70    fn default() -> Self {
71        Self::plain_tex_defaults()
72    }
73}
74
75impl SpaceFactorCodes {
76    pub fn plain_tex_defaults() -> Self {
77        let mut a = [1000_i32; 256];
78        for (c, value) in [
79            // From plain.tex
80            (')', 0),
81            ('\'', 0),
82            (']', 0),
83            // From \nonfrenchspacing in plain.tex
84            ('.', 3000),
85            ('?', 3000),
86            ('!', 3000),
87            (':', 2000),
88            (';', 1500),
89            (',', 1250),
90        ] {
91            a[c as usize] = value;
92        }
93        for c in 'A'..='Z' {
94            // INITTEX
95            a[c as usize] = 999;
96        }
97        Self(a)
98    }
99}
100
101#[derive(Debug, PartialEq, Eq, Clone, Copy)]
102pub struct SpaceFactor(pub i32);
103
104impl Default for SpaceFactor {
105    fn default() -> Self {
106        Self(1000)
107    }
108}
109
110impl SpaceFactor {
111    fn adjust(&mut self, c: char, codes: &SpaceFactorCodes) {
112        // TeX.2021.1034
113        let new: i32 = codes.0.get(c as usize).copied().unwrap_or(1000);
114        if new > 0 && new <= 1000 {
115            self.0 = new;
116        } else if new > 1000 {
117            if self.0 < 1000 {
118                self.0 = 1000
119            } else {
120                self.0 = new
121            }
122        }
123    }
124}
125
126impl TextPreprocessor {
127    pub fn activate_font(&mut self, font: font::Id) {
128        self.current_font = font;
129    }
130
131    pub fn new_paragraph(&mut self) {
132        self.space_factor = Default::default();
133    }
134
135    pub fn add_word<Font: font::TextBuilder>(
136        &mut self,
137        font_repo: &font::Repo<Font>,
138        word: &str,
139        list: &mut Vec<ds::Horizontal>,
140    ) {
141        // TeX.2021.1034
142        let font = font_repo.get(self.current_font);
143        for elem in font.build_text(word.chars(), Default::default()) {
144            use font::TextItem::*;
145            match elem {
146                Char(c) => {
147                    list.push(
148                        ds::Char {
149                            char: c,
150                            font: self.current_font,
151                        }
152                        .into(),
153                    );
154                    // TeX.2021.1035
155                    // TODO: \hyphenchar
156                    if c == '-' {
157                        list.push(ds::Discretionary::default().into());
158                    }
159                }
160                Kern(kern) => {
161                    list.push(
162                        ds::Kern {
163                            width: kern,
164                            kind: ds::KernKind::Normal,
165                        }
166                        .into(),
167                    );
168                }
169                Ligature {
170                    c,
171                    original,
172                    includes_left_boundary,
173                    includes_right_boundary,
174                } => {
175                    let ins_disc = original.as_ref().ends_with('-');
176                    list.push(
177                        ds::Ligature {
178                            char: c,
179                            font: self.current_font,
180                            original_chars: original,
181                            includes_left_boundary,
182                            includes_right_boundary,
183                        }
184                        .into(),
185                    );
186                    // TeX.2021.1035
187                    // TODO: \hyphenchar
188                    if ins_disc {
189                        list.push(ds::Discretionary::default().into());
190                    }
191                }
192            }
193        }
194        // TODO: consider merging this loop with the loop in the lig/kern program.
195        // We can change the run method to accept a callback that is invoked for
196        // each character.
197        for c in word.chars() {
198            self.space_factor.adjust(c, &self.params.space_factor_codes);
199        }
200    }
201
202    pub fn add_space<Font: font::Format>(
203        &mut self,
204        font_repo: &font::Repo<Font>,
205        list: &mut Vec<ds::Horizontal>,
206    ) {
207        let g = if self.space_factor == SpaceFactor::default() {
208            // TeX.2021.1041
209            if !self.params.space_skip.is_zero() {
210                self.params.space_skip
211            } else {
212                // TeX.2021.1042
213                font_repo.get(self.current_font).default_space()
214            }
215        } else {
216            // TeX.2021.1043
217            if self.space_factor.0 >= 2000 && !self.params.extra_space_skip.is_zero() {
218                self.params.extra_space_skip
219            } else if !self.params.space_skip.is_zero() {
220                self.params.space_skip
221            } else {
222                // TeX.2021.1042
223                let mut g = font_repo.get(self.current_font).default_space();
224                // TeX.2021.1044
225                if self.space_factor.0 >= 2000 {
226                    g.width += font_repo.get(self.current_font).extra_space();
227                }
228                g.stretch = g.stretch.xn_over_d(self.space_factor.0, 1000).unwrap().0;
229                g.shrink = g.shrink.xn_over_d(1000, self.space_factor.0).unwrap().0;
230                g
231            }
232        };
233        list.push(ds::Horizontal::Glue(g.into()));
234    }
235
236    pub fn add_text<Font: font::Format + font::TextBuilder>(
237        &mut self,
238        font_repo: &font::Repo<Font>,
239        text: &str,
240        list: &mut Vec<ds::Horizontal>,
241    ) {
242        self.new_paragraph();
243        let mut pending_space = text.chars().next().unwrap_or(' ').is_ascii_whitespace();
244        for word in text.split_ascii_whitespace() {
245            if pending_space {
246                self.add_space(font_repo, list);
247            }
248            let word = word.trim_matches(' ');
249            self.add_word(font_repo, word.trim_matches(' '), list);
250            pending_space = true;
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use std::collections::HashMap;
258
259    use super::*;
260    use boxworks_testing;
261    use boxworks_testing::assert_box_eq;
262    use boxworks_testing::assert_box_lossy_eq;
263
264    macro_rules! preprocessor_tests {
265        (
266            $namespace: ident,
267            $tfm: ident,
268            $( (
269                $name: ident,
270                $input: expr,
271                $want: expr,
272                $( params: Params {
273                    $( $param_name: ident: $param_value: expr, )+
274                }, )?
275            ), )+ ) => {
276                mod $namespace {
277                    use super::*;
278                    $(
279                        #[test]
280                        fn $name() {
281                            let tfm = super::$tfm;
282                            let input = $input;
283                            let want = $want;
284                            let params = Params {
285                                $( $(
286                                    $param_name: $param_value,
287                                )+ )?
288                                .. Params::plain_tex_defaults()
289                            };
290                            run_preprocessor_test(tfm, params, input, want)
291                        }
292                    )+
293                }
294        };
295    }
296
297    const TFM_CMR10: &'static [u8] = include_bytes!("../../tfm/corpus/computer-modern/cmr10.tfm");
298
299    preprocessor_tests!(
300        cmr10,
301        TFM_CMR10,
302        (
303            basic,
304            "second",
305            r#"
306                chars("second")
307            "#,
308        ),
309        (
310            basic_with_space,
311            "sec ond",
312            r#"
313                chars("sec")
314                glue(3.33333pt, 1.66666pt, 1.11111pt)
315                chars("ond")
316            "#,
317        ),
318        (
319            kern_ao,
320            "AO",
321            r#"
322                chars("A")
323                kern(-0.27779pt)
324                chars("O")
325            "#,
326        ),
327        (
328            kern_av,
329            "AV",
330            r#"
331                chars("A")
332                kern(-1.11113pt)
333                chars("V")
334            "#,
335        ),
336        (
337            ligature_1,
338            "ff",
339            r#"
340                lig("\u{b}", "ff")
341            "#,
342        ),
343        (
344            ligature_2,
345            "ffi",
346            r#"
347                lig("\u{e}", "ffi")
348            "#,
349        ),
350        (
351            ragged_right,
352            "a b. c",
353            r##"
354                chars("a")
355                glue(3.33298pt, 0.0pt, 0.0pt)
356                chars("b.")
357                glue(5.0pt, 0.0pt, 0.0pt)
358                chars("c")
359            "##,
360            params: Params {
361                space_skip: common::Glue {
362                    width: common::Scaled::parse_from_string("3.33298pt").unwrap(),
363                    ..Default::default()
364                },
365                extra_space_skip: common::Glue {
366                    width: common::Scaled::parse_from_string("5.0pt").unwrap(),
367                    ..Default::default()
368                },
369            },
370        ),
371    );
372
373    macro_rules! spacing_tests {
374        ( $( ( $name: ident, $input: expr, $want: expr, ), )+ ) => {
375            mod spacing {
376                $(
377                    #[test]
378                    fn $name() {
379                        let tfm = super::TFM_CMR10;
380                        let input = format!["{} a", $input];
381                        let want =  format![r#"
382                            chars("{}")
383                            {}
384                            chars("a")
385                        "#, $input, $want];
386                        super::run_preprocessor_test(tfm, Default::default(), &input, &want)
387                    }
388                )+
389            }
390        };
391    }
392
393    spacing_tests!(
394        // These tests are testing the default space factors in plain.tex.
395        (default_1, "a;", "glue(3.33333pt, 2.49998pt, 0.74074pt)",),
396        (default_2, "a,", "glue(3.33333pt, 2.08331pt, 0.88889pt)",),
397        (default_3, "a.", "glue(4.44444pt, 4.99997pt, 0.37036pt)",),
398        (default_4, "a:", "glue(4.44444pt, 3.33331pt, 0.55556pt)",),
399        // The next tests are for the adjust_space_factor function.
400        // The SF is adjusted based on both its current value and the SF
401        // of the next character. We first test 16 possible cases where
402        // current and next are in the following 4 classes: zero, small
403        // (less than 1000), normal (1000), large (greater than 1000).
404        (
405            adjust_space_factor_zero_zero,
406            "))",
407            "glue(3.33333pt, 1.66666pt, 1.11111pt)",
408        ),
409        (
410            adjust_space_factor_zero_small,
411            ")A",
412            "glue(3.33333pt, 1.66498pt, 1.11221pt)",
413        ),
414        (
415            adjust_space_factor_zero_normal,
416            ")a",
417            "glue(3.33333pt, 1.66666pt, 1.11111pt)",
418        ),
419        (
420            adjust_space_factor_zero_large,
421            ").",
422            "glue(4.44444pt, 4.99997pt, 0.37036pt)",
423        ),
424        (
425            adjust_space_factor_small_zero,
426            "A)",
427            "glue(3.33333pt, 1.66498pt, 1.11221pt)",
428        ),
429        (
430            adjust_space_factor_small_small,
431            "AA",
432            "glue(3.33333pt, 1.66498pt, 1.11221pt)",
433        ),
434        (
435            adjust_space_factor_small_normal,
436            "Aa",
437            "glue(3.33333pt, 1.66666pt, 1.11111pt)",
438        ),
439        (
440            adjust_space_factor_small_large,
441            "A.",
442            "glue(3.33333pt, 1.66666pt, 1.11111pt)",
443        ),
444        (
445            adjust_space_factor_normal_zero,
446            "a)",
447            "glue(3.33333pt, 1.66666pt, 1.11111pt)",
448        ),
449        (
450            adjust_space_factor_normal_small,
451            "aA",
452            "glue(3.33333pt, 1.66498pt, 1.11221pt)",
453        ),
454        (
455            adjust_space_factor_normal_normal,
456            "aa",
457            "glue(3.33333pt, 1.66666pt, 1.11111pt)",
458        ),
459        (
460            adjust_space_factor_normal_large,
461            "a.",
462            "glue(4.44444pt, 4.99997pt, 0.37036pt)",
463        ),
464        (
465            adjust_space_factor_large_zero,
466            ".)",
467            "glue(4.44444pt, 4.99997pt, 0.37036pt)",
468        ),
469        (
470            adjust_space_factor_large_small,
471            ".A",
472            "glue(3.33333pt, 1.66498pt, 1.11221pt)",
473        ),
474        (
475            adjust_space_factor_large_normal,
476            ".a",
477            "glue(3.33333pt, 1.66666pt, 1.11111pt)",
478        ),
479        (
480            adjust_space_factor_large_large,
481            "..",
482            "glue(4.44444pt, 4.99997pt, 0.37036pt)",
483        ),
484    );
485
486    const TFM_SMFEBSL: &'static [u8] = include_bytes!("../../tfm/corpus/ctan/smfebsl10-3.tfm");
487
488    preprocessor_tests!(
489        smfebsl,
490        TFM_SMFEBSL,
491        (
492            basic_with_space,
493            "sec ond",
494            r#"
495                chars("sec")
496                glue(4.78204pt, 2.39102pt, 1.19551pt)
497                chars("on")
498                kern(-0.49814pt)
499                chars("d")
500            "#,
501        ),
502        (
503            numbers_start_of_word,
504            "123B",
505            r##"
506                lig("$", "", includes_left_boundary="true")
507                chars("123")
508                lig("#", "")
509                chars("B")
510            "##,
511        ),
512        (
513            numbers_mid_word,
514            "A123B",
515            r##"
516                chars("A")
517                lig("$", "")
518                chars("123")
519                lig("#", "")
520                chars("B")
521            "##,
522        ),
523        (
524            numbers_end_of_word,
525            "A123",
526            r##"
527                chars("A")
528                lig("$", "")
529                chars("123")
530                lig("#", "", includes_right_boundary="true")
531            "##,
532        ),
533    );
534
535    fn run_preprocessor_test(tfm_bytes: &[u8], params: Params, input: &str, want: &str) {
536        if std::env::var("TEXCRAFT_VERIFY").unwrap_or_default() == "tex" {
537            use std::path::PathBuf;
538            let mut auxiliary_files: HashMap<PathBuf, Vec<u8>> = Default::default();
539            auxiliary_files.insert("customFont.tfm".into(), tfm_bytes.to_vec());
540            let preamble = format!(
541                r"
542                {}
543                \font \customFont customFont
544                \customFont
545                ",
546                params.tex(),
547            );
548            let mut tex_engine = boxworks::tex::new_tex_engine_binary("tex".to_string()).unwrap();
549            let (_, mut tex_got) = boxworks::tex::build_horizontal_lists(
550                tex_engine.as_mut(),
551                &auxiliary_files,
552                &preamble,
553                &mut [input.to_string()].iter(),
554                /*hyphenate=*/ false,
555            );
556            let tex_got = tex_got.remove(0).list;
557            // The lossy comparison is used because TeX's box dumps represent
558            // the boundary character in a ligature's original characters with
559            // a `|` marker rather than as separate fields.
560            assert_box_lossy_eq!(want, tex_got);
561            return;
562        }
563
564        let tfm_font = tfm::Font::build_from_bytes(tfm_bytes)
565            .expect("tfm file is valid")
566            .0;
567        let mut font_repo: font::Repo<tfm::Font> = Default::default();
568        let font_id = font_repo.register(tfm_font);
569
570        let mut tp = TextPreprocessor::new(params);
571        tp.activate_font(font_id);
572        let mut got = vec![];
573        for word in input.split_inclusive(' ') {
574            tp.add_word(&font_repo, word.trim_matches(' '), &mut got);
575            if word.ends_with(" ") {
576                tp.add_space(&font_repo, &mut got);
577            }
578        }
579
580        assert_box_eq!(got, want);
581    }
582}