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