boxworks_hyphenate/
lib.rs

1use boxworks::ds;
2use tfm::ligkern::RunOptions;
3
4pub struct Hyphenator {
5    // TODOs:
6    // (1) Don't depend on the tfm crate. There should be kind of abstraction here
7    // so that this works with all font types. Maybe the solution is to have a ligkern
8    // crate that contains the non-tfm logic for lig/kern programs. Or some boxworks
9    // abstractions.
10    // (2) Support changing the font!
11    pub lig_kern_program: tfm::ligkern::CompiledProgram,
12    pub hyphenator: hyphenate::Hyphenator,
13    pub left_hyphen_min: i32,
14    pub right_hyphen_min: i32,
15}
16
17impl Hyphenator {
18    /// Creates a hyphenator with plain TeX's English patterns and defaults.
19    ///
20    /// The lig/kern program of the font being hyphenated is required because
21    /// hyphenation breaks ligatures apart to insert discretionaries, and the
22    /// program is needed to reconstitute ligatures and kerns in the result.
23    /// Passing the wrong program (e.g. an empty one) silently produces
24    /// un-ligatured output.
25    pub fn plain_tex_en_us(lig_kern_program: tfm::ligkern::CompiledProgram) -> Self {
26        Self {
27            lig_kern_program,
28            hyphenator: hyphenate::Hyphenator::plain_tex_en_us(),
29            left_hyphen_min: 2,
30            right_hyphen_min: 3,
31        }
32    }
33}
34
35impl boxworks::Hyphenator for Hyphenator {
36    fn hyphenate(&self, list: &mut Vec<ds::Horizontal>) {
37        let out = hyphenate_impl(self, list);
38        *list = out;
39    }
40}
41
42fn hyphenate_impl(hyphenater: &Hyphenator, list: &[ds::Horizontal]) -> Vec<ds::Horizontal> {
43    let lower_caser = hyphenate::AsciiLowerCaser::default();
44    let mut out = vec![];
45    let mut i = 0;
46    while let Some(elem) = list.get(i) {
47        i += 1;
48        out.push(elem.clone());
49        // Hyphenation starts only at glue nodes, as per TeX.2021.866.
50        if !matches!(elem, ds::Horizontal::Glue(_)) {
51            continue;
52        }
53
54        // TODO: hyf_char needs to be read from somewhere using the font numbers
55        // in char and lig nodes.
56        let hyf_char = 3_i32;
57
58        // Find the place to start hyphenating
59        // TeX.2021.896
60        let hyphenation_font: Option<common::FontId> = loop {
61            let Some(elem) = list.get(i) else { break None };
62            enum Action {
63                Start { font: common::FontId },
64                Continue,
65                // Equivalent to done1 in Knuth's TeX.
66                Abort,
67            }
68            use ds::Horizontal::*;
69            let action = match elem {
70                Char(char) => {
71                    // The hyf_char logic runs in the label done2.
72                    // TODO: this code assumes \uchyph=true (uppercase letters are hyphenated)
73                    // and that \lccode has its PlainTeX values (has lower case character iff
74                    // ASCII alphabetic). We should remove these assumptions by plumbing in some
75                    // parameters.
76                    if char.char.is_ascii_alphabetic() {
77                        if hyf_char > 0 && hyf_char <= 255 {
78                            Action::Start { font: char.font }
79                        } else {
80                            Action::Abort
81                        }
82                    } else {
83                        Action::Continue
84                    }
85                }
86                Ligature(ligature) => {
87                    match ligature.original_chars.chars().next() {
88                        None => Action::Continue,
89                        Some(char) => {
90                            // TODO: all of the TODOs in the Char arm.
91                            if char.is_ascii_alphabetic() {
92                                if hyf_char > 0 && hyf_char <= 255 {
93                                    Action::Start {
94                                        font: ligature.font,
95                                    }
96                                } else {
97                                    Action::Abort
98                                }
99                            } else {
100                                Action::Continue
101                            }
102                        }
103                    }
104                }
105                Whatsit(whatsit) => {
106                    // TeX.2021.1363 but given how we've architected the code, the logic in TeX.2021.1382
107                    // (which changes the current language) should run here.
108                    whatsit.hyphenation_hook();
109                    Action::Continue
110                }
111                Kern(kern) => match kern.kind {
112                    ds::KernKind::Normal => Action::Continue,
113                    _ => Action::Abort,
114                },
115                _ => Action::Abort,
116            };
117            match action {
118                Action::Start { font } => {
119                    break Some(font);
120                }
121                Action::Continue => {
122                    i += 1;
123                    out.push(elem.clone());
124                }
125                Action::Abort => {
126                    i += 1;
127                    out.push(elem.clone());
128                    break None;
129                }
130            }
131        };
132        let Some(hyphenation_font) = hyphenation_font else {
133            continue;
134        };
135        // It's still possible we won't hyphenate based on the node that ends the
136        // string of characters. So we save this `i` value here; if hyphenation is skipped
137        // we set `i` back to this.
138        let hyphenation_start_i = i;
139
140        let mut s = String::new();
141        // Accumulate the word to be hyphenated.
142        // TeX.2021.897
143        let right_boundary_override: Option<char> =
144            loop {
145                let Some(elem) = list.get(i) else { break None };
146                use ds::Horizontal::*;
147                match elem {
148                    Char(char) => {
149                        if char.font != hyphenation_font {
150                            // TODO: add tests for this case including left/right boundary behaviour.
151                            break None;
152                        }
153                        // TODO: plumb in \lccode and change this check.
154                        if !char.char.is_ascii_alphabetic() {
155                            break Some(char.char);
156                        }
157                        if s.len() + char.char.len_utf8() >= 64 {
158                            // TeX only hyphenates words up to 64 bytes.
159                            // TODO: this check is not quite right: unicode values in the range [128, 255)
160                            // should count as 1 only.
161                            break Some(char.char);
162                        }
163                        s.push(char.char);
164                    }
165                    Ligature(ligature) => {
166                        // TeX.2021.898
167                        if ligature.font != hyphenation_font {
168                            // TODO: add tests for this case including left/right boundary behaviour.
169                            break None;
170                        }
171                        if !ligature
172                            .original_chars
173                            .chars()
174                            .all(|c| c.is_ascii_alphabetic())
175                        {
176                            break Some(ligature.original_chars.chars().next().expect(
177                                "there must be at least one char for this branch to execute",
178                            ));
179                        }
180                        if s.len() + ligature.original_chars.len() >= 64 {
181                            // TeX only hyphenates words up to 64 bytes.
182                            // TODO: this check is not quite right: unicode values in the range [128, 255)
183                            // should count as 1 only.
184                            break Some(ligature.original_chars.chars().next().expect(
185                                "there must be at least one char for this branch to execute",
186                            ));
187                        }
188                        s.push_str(&ligature.original_chars);
189                    }
190                    Kern(kern) => match kern.kind {
191                        ds::KernKind::Normal => {
192                            // TODO: set up the lig/kern program correctly.
193                        }
194                        _ => break None,
195                    },
196                    _ => break None,
197                }
198                // Consume the node whose characters have just been placed in s (or the normal kern).
199                i += 1;
200            };
201        // The first char node that triggered the word search will have been put in s.
202        assert!(!s.is_empty());
203
204        // Check if the word can be hyphenated based on the terminating node.
205        // TeX.2021.899
206        // We use a different index to iterate as all of the elements here still need to be
207        // copied to the output list.
208        let mut j = i;
209        let should_hyphenate = loop {
210            let Some(elem) = list.get(j) else { break true };
211            use ds::Horizontal::*;
212            match elem {
213                Char(_) | Ligature(_) => {
214                    // This can happen if the font is different, or the 64 byte limit is already
215                    // reached.
216                }
217                Kern(kern) => match kern.kind {
218                    ds::KernKind::Normal => {
219                        // TODO: set up the lig/kern program correctly.
220                    }
221                    _ => break true,
222                },
223                Whatsit(_) | Glue(_) | Penalty(_) | Insertion(_) | Adjust(_) | Mark(_) => {
224                    break true;
225                }
226                HBox(_) | VBox(_) | Rule(_) | Discretionary(_) | Math(_) => {
227                    // done1 in Knuth's TeX.f
228                    break false;
229                }
230            }
231            j += 1;
232        };
233        if !should_hyphenate {
234            i = hyphenation_start_i;
235            continue;
236        }
237
238        let l = s.chars().count();
239
240        let mut indices = {
241            let indices = hyphenater.hyphenator.calculate_indices(&lower_caser, &s);
242            // TeX.2021.1200
243            let left_hyphen_min: usize = match hyphenater.left_hyphen_min.try_into() {
244                Ok(0) | Err(_) => 1,
245                Ok(i) => i,
246            };
247            let right_hyphen_min: usize = match hyphenater.right_hyphen_min.try_into() {
248                Ok(0) | Err(_) => 1,
249                Ok(i) => i,
250            };
251            let hyph_max = l.saturating_sub(right_hyphen_min);
252            IndexIter::new(indices, left_hyphen_min, hyph_max)
253        };
254        let mut next_or = indices.next();
255
256        let mut main_iter = hyphenater.lig_kern_program.run_with_options(
257            s.chars(),
258            RunOptions {
259                disable_left_boundary: false,
260                right_boundary_override,
261            },
262        );
263
264        use tfm::ligkern::RunItem;
265
266        let mut chars_pushed = 0;
267        let mut elements_since_separation_point = 0_usize;
268        let mut start_of_separation_point = 0_usize;
269        // This corresponds to the loop in TeX.2021.913 but not 1-1.
270        //
271        // TeX's loop is across all cut prefixes whereas this loop is over each
272        // individual lig/kern element that is emitted.
273        // We iterate over all elements, but do have some logic at the start of the
274        // loop if we are at a separation point.
275        //
276        // Knuth's reconstitute method sets hyphen_passed>0 if either the main lig/kern
277        // program ran over a hyphen while processing the cut prefix,
278        // or if the hyphen lig/kern program is non-trivial and
279        // thus needs to run. His reconstitute method does *not* consider the regular case where the
280        // hyphen does not interact with the lig/kern program; this case is handled in the
281        // body of section 913.
282        loop {
283            if main_iter.is_separation_point() {
284                elements_since_separation_point = 0;
285                start_of_separation_point = chars_pushed;
286            }
287            let Some(elem) = main_iter.next() else { break };
288
289            let (num_chars, last_char, original_elem): (usize, Option<char>, ds::Horizontal) =
290                match elem {
291                    RunItem::Char(c) => (
292                        1,
293                        Some(c),
294                        ds::Char {
295                            char: c,
296                            font: hyphenation_font,
297                        }
298                        .into(),
299                    ),
300                    RunItem::Kern(scaled) => (
301                        0,
302                        None,
303                        ds::Kern {
304                            width: scaled,
305                            kind: ds::KernKind::Normal,
306                        }
307                        .into(),
308                    ),
309                    RunItem::Ligature(ligature) => (
310                        ligature.original.chars().count(),
311                        Some(ligature.c),
312                        ds::Ligature {
313                            includes_left_boundary: ligature.includes_left_boundary,
314                            includes_right_boundary: ligature.includes_right_boundary,
315                            char: ligature.c,
316                            font: hyphenation_font,
317                            original_chars: ligature.original,
318                        }
319                        .into(),
320                    ),
321                };
322            // It would be nice to debug assert that this is equal to the original element in the list,
323            // but there are buggy cases in TeX in which this is not the case.
324            // The unit tests cover these cases.
325            out.push(original_elem);
326            chars_pushed += num_chars;
327            elements_since_separation_point += 1;
328            let Some(last_char) = last_char else { continue };
329
330            let hyph_next = next_or.unwrap_or(usize::MAX);
331            if hyph_next > chars_pushed {
332                // No hyphen here.
333                continue;
334            }
335
336            let is_hyphen_rule = hyphenater
337                .lig_kern_program
338                .has_replacement(Some(last_char), Some('-'));
339
340            // If the hyphen is exactly at a separation point and if the lig/kern program with
341            // the hyphen is also at a separation point (e.g. is_hyphen_rule=false) then we advance
342            // to this separation point. Otherwise, the discretionary is built from looking at
343            // material since the last separation point.
344            if hyph_next == chars_pushed && main_iter.is_separation_point() && !is_hyphen_rule {
345                elements_since_separation_point = 0;
346                start_of_separation_point = chars_pushed;
347            }
348
349            // This is the loop in TeX.2021.914.
350            loop {
351                let hyph_next = next_or.unwrap_or(usize::MAX);
352                let pre_break_text = s[start_of_separation_point..hyph_next]
353                    .chars()
354                    .chain("-".chars());
355                let pre_break: Vec<ds::DiscretionaryElem> = hyphenater
356                    .lig_kern_program
357                    .run_with_options(
358                        pre_break_text,
359                        RunOptions {
360                            // The pre-break text always starts at a separation point so we don't
361                            // need to do any left boundary processing. Moreover, if we did the default
362                            // left boundary processing we would get the wrong result because the pre-
363                            // break text is not preceded by the start of a word.
364                            // This is all covered in unit tests.
365                            disable_left_boundary: true,
366                            right_boundary_override: None,
367                        },
368                    )
369                    .map(|elem| {
370                        let d: ds::DiscretionaryElem = match elem {
371                            RunItem::Char(c) => ds::Char {
372                                char: c,
373                                font: hyphenation_font,
374                            }
375                            .into(),
376                            RunItem::Kern(scaled) => ds::Kern {
377                                width: scaled,
378                                kind: ds::KernKind::Normal,
379                            }
380                            .into(),
381                            RunItem::Ligature(ligature) => ds::Ligature {
382                                char: ligature.c,
383                                font: hyphenation_font,
384                                includes_left_boundary: ligature.includes_left_boundary,
385                                includes_right_boundary: ligature.includes_right_boundary,
386                                original_chars: ligature.original.clone(),
387                            }
388                            .into(),
389                        };
390                        d
391                    })
392                    .collect();
393
394                let post_break_text = &s[hyph_next..];
395                let mut post_break_iter = hyphenater.lig_kern_program.run_with_options(
396                    post_break_text.chars(),
397                    RunOptions {
398                        disable_left_boundary: false,
399                        right_boundary_override,
400                    },
401                );
402                let mut post_break: Vec<ds::DiscretionaryElem> = vec![];
403                let mut post_chars_pushed = hyph_next;
404
405                let disc_insert_point = out
406                    .len()
407                    .checked_sub(elements_since_separation_point)
408                    .expect("not popping more elements than have been pushed");
409
410                // This is the loop in TeX.2021.916.
411                // We want to achieve synchronization for the two iterators.
412                //
413                // The following boolean handles the first if statement in TeX.2021.916.
414                let mut post_char_left_boundary = hyphenater
415                    .lig_kern_program
416                    .has_replacement(None, post_break_text.chars().next());
417                loop {
418                    if !post_char_left_boundary
419                        && post_chars_pushed == chars_pushed
420                        && post_break_iter.is_separation_point()
421                        && main_iter.is_separation_point()
422                    {
423                        break;
424                    }
425                    if post_char_left_boundary || post_chars_pushed < chars_pushed {
426                        post_char_left_boundary = false;
427                        while let Some(elem) = post_break_iter.next() {
428                            post_chars_pushed += match &elem {
429                                RunItem::Char(_) => 1,
430                                RunItem::Kern(_) => 0,
431                                RunItem::Ligature(ligature) => ligature.original.chars().count(),
432                            };
433                            post_break.push(match elem {
434                                RunItem::Char(c) => ds::Char {
435                                    char: c,
436                                    font: hyphenation_font,
437                                }
438                                .into(),
439                                RunItem::Kern(scaled) => ds::Kern {
440                                    width: scaled,
441                                    kind: ds::KernKind::Normal,
442                                }
443                                .into(),
444                                RunItem::Ligature(ligature) => ds::Ligature {
445                                    char: ligature.c,
446                                    font: hyphenation_font,
447                                    includes_left_boundary: ligature.includes_left_boundary,
448                                    includes_right_boundary: ligature.includes_right_boundary,
449                                    original_chars: ligature.original.clone(),
450                                }
451                                .into(),
452                            });
453                            if post_break_iter.is_separation_point() {
454                                break;
455                            }
456                        }
457                    } else {
458                        while let Some(elem) = main_iter.next() {
459                            chars_pushed += match &elem {
460                                RunItem::Char(_) => 1,
461                                RunItem::Kern(_) => 0,
462                                RunItem::Ligature(ligature) => ligature.original.chars().count(),
463                            };
464                            out.push(match elem {
465                                RunItem::Char(c) => ds::Char {
466                                    char: c,
467                                    font: hyphenation_font,
468                                }
469                                .into(),
470                                RunItem::Kern(scaled) => ds::Kern {
471                                    width: scaled,
472                                    kind: ds::KernKind::Normal,
473                                }
474                                .into(),
475                                RunItem::Ligature(ligature) => ds::Ligature {
476                                    char: ligature.c,
477                                    font: hyphenation_font,
478                                    includes_left_boundary: ligature.includes_left_boundary,
479                                    includes_right_boundary: ligature.includes_left_boundary,
480                                    original_chars: ligature.original,
481                                }
482                                .into(),
483                            });
484                            if main_iter.is_separation_point() {
485                                break;
486                            }
487                        }
488                    }
489                }
490                let replace_count = out
491                    .len()
492                    .checked_sub(disc_insert_point)
493                    .expect("have only added to out since calculating disc_insert_point");
494                out.insert(
495                    disc_insert_point,
496                    ds::Discretionary {
497                        pre_break,
498                        post_break,
499                        replace_count: replace_count.try_into().unwrap(),
500                    }
501                    .into(),
502                );
503
504                // We increment the hyphen index at least once to account for the hyphen we have just inserted.
505                next_or = indices.next();
506                // When performing synchronization we may have passed over some hyphens.
507                while next_or.unwrap_or(usize::MAX) < chars_pushed {
508                    next_or = indices.next();
509                }
510                if next_or.unwrap_or(usize::MAX) > chars_pushed {
511                    // We need to consume more characters before trying more hyphens
512                    break;
513                }
514                elements_since_separation_point = 0;
515                start_of_separation_point = chars_pushed;
516            }
517        }
518    }
519    out
520}
521
522struct IndexIter<I> {
523    inner: I,
524    min: usize,
525    max: usize,
526}
527
528impl<I: Iterator<Item = usize>> IndexIter<I> {
529    fn new(inner: I, min: usize, max: usize) -> Self {
530        Self { inner, min, max }
531    }
532}
533
534impl<I: Iterator<Item = usize>> Iterator for IndexIter<I> {
535    type Item = usize;
536
537    fn next(&mut self) -> Option<Self::Item> {
538        let n = self.inner.next()?;
539        if n >= self.min && n <= self.max {
540            return Some(n);
541        }
542        self.next()
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use std::collections::HashMap;
549    use std::path::PathBuf;
550
551    use super::*;
552    use boxworks::TextPreprocessor;
553    use boxworks_testing::assert_box_eq;
554    use boxworks_testing::assert_box_lossy_eq;
555    use boxworks_text as bwt;
556
557    const TFM_CMR10: &'static [u8] = include_bytes!("../../tfm/corpus/computer-modern/cmr10.tfm");
558
559    fn run_hyphenation_test(tc: TestCase, lossy: bool) {
560        let unhyphenated: String = tc.input.chars().filter(|c| *c != '-').collect();
561        let hyphenation_patterns = tc.hyphenation_patterns.unwrap_or(&tc.input);
562
563        // TeX does not hyphenate the first word of a paragraph so we need to put
564        // another word before the word of interest
565        let tex_input = format!["x {unhyphenated}"];
566
567        let mut tfm_file = tfm::File::deserialize(TFM_CMR10).0.unwrap();
568        {
569            let (p, e) = tfm::ligkern::lang::Program::parse_compact(tc.lig_kern_program).unwrap();
570            tfm_file.replace_lig_kern_program(p, e);
571        }
572
573        if std::env::var("TEXCRAFT_VERIFY").unwrap_or("".to_string()) == "tex" {
574            let tfm_bytes = tfm_file.serialize();
575            let mut auxiliary_files: HashMap<PathBuf, Vec<u8>> = Default::default();
576            auxiliary_files.insert("specialFont.tfm".into(), tfm_bytes);
577
578            let preamble = format![
579                r"
580                    \font \customFont specialFont
581                    
582                    \hyphenation{{{}}}
583                    \lefthyphenmin={}
584                    \righthyphenmin=0
585                    
586                    \customFont
587                ",
588                hyphenation_patterns,
589                tc.left_hyphen_min.unwrap_or(1),
590            ];
591            let mut tex_engine = boxworks::tex::new_tex_engine_binary("tex".to_string()).unwrap();
592            let (_, tex_got) = boxworks::tex::build_horizontal_lists(
593                tex_engine.as_mut(),
594                &auxiliary_files,
595                &preamble,
596                &mut vec![tex_input.clone()].iter(),
597                /*hyphenate=*/ true,
598            );
599            let tex_got: Vec<boxworks::ds::Horizontal> =
600                tex_got[0].list[2..].iter().cloned().collect();
601
602            if lossy {
603                assert_box_lossy_eq!(tc.want, tex_got);
604            } else {
605                assert_box_eq!(tc.want, tex_got);
606            }
607            return;
608        }
609
610        let lig_kern_program =
611            tfm::ligkern::CompiledProgram::compile_from_tfm_file(&mut tfm_file).0;
612        let mut tp = bwt::TextPreprocessorImpl::new(bwt::Params::plain_tex_defaults());
613        tp.register_font(common::FontId::ONE, &tfm_file, lig_kern_program.clone());
614        tp.activate_font(common::FontId::ONE);
615        let mut list = vec![];
616        for word in tex_input.split_ascii_whitespace() {
617            tp.add_word(word.trim_matches(' '), &mut list);
618            tp.add_space(&mut list);
619        }
620        list.pop();
621
622        let mut font_repo: bwt::TfmFontRepo = Default::default();
623        font_repo.register_font(common::FontId::ONE, tfm_file);
624
625        let mut hyphenator = Hyphenator::plain_tex_en_us(lig_kern_program);
626        hyphenator
627            .hyphenator
628            .insert_exceptions(hyphenation_patterns);
629        hyphenator.left_hyphen_min = tc.left_hyphen_min.unwrap_or(1);
630        hyphenator.right_hyphen_min = 1;
631        {
632            use boxworks::Hyphenator;
633            hyphenator.hyphenate(&mut list)
634        }
635
636        let tex_got: Vec<boxworks::ds::Horizontal> = list[2..].iter().cloned().collect();
637        assert_box_eq!(tc.want, tex_got);
638    }
639
640    macro_rules! hyphenation_tests {
641        ( $( {
642            $name: ident,
643            TestCase {
644                $( $field: ident: $value: expr, )*
645            },
646            $(lossy: $lossy: expr,)?
647        }, )* ) => {
648            $(
649                #[test]
650                fn $name() {
651                    let test_case = TestCase {
652                        $( $field: $value, )*
653                        ..Default::default()
654                    };
655                    let lossy = false $( || $lossy )?;
656                    run_hyphenation_test(test_case, lossy);
657                }
658            )*
659        };
660    }
661
662    #[derive(Default)]
663    struct TestCase {
664        input: &'static str,
665        lig_kern_program: &'static str,
666        want: &'static str,
667        hyphenation_patterns: Option<&'static str>,
668        left_hyphen_min: Option<i32>,
669    }
670
671    hyphenation_tests![
672        {
673            no_hyphens,
674            TestCase {
675                input: "mint",
676                lig_kern_program: "",
677                want: r#"
678                    chars("mint")
679                "#,
680            },
681        },
682        {
683            most_simple_case,
684            TestCase {
685                input: "a-b",
686                lig_kern_program: "",
687                want: r#"
688                    chars("a")
689                    disc(
690                      pre_break=[
691                        chars("-")
692                      ],
693                    )
694                    chars("b")
695                "#,
696            },
697        },
698        {
699            lig_1,
700            TestCase {
701                input: "a-b",
702                lig_kern_program: "
703                    ab -> axb^
704                ",
705                want: r#"
706                    disc(
707                      pre_break=[
708                        chars("a-")
709                      ],
710                      replace_count=2,
711                    )
712                    chars("a")
713                    lig("x", "")
714                    chars("b")
715                "#,
716            },
717        },
718        {
719            lig_with_hyphen,
720            TestCase {
721                input: "a-b",
722                lig_kern_program: "
723                    a- -> ax-^
724                ",
725                want: r#"
726                    disc(
727                      pre_break=[
728                        chars("a")
729                        lig("x", "")
730                        chars("-")
731                      ],
732                      replace_count=1,
733                    )
734                    chars("a")
735                    chars("b")
736                "#,
737            },
738        },
739        {
740            lig_with_hyphen_and_letters,
741            TestCase {
742                input: "a-b",
743                lig_kern_program: "
744                    a- -> ax-^
745                    ab -> ac^_
746                ",
747                want: r#"
748                    disc(
749                      pre_break=[
750                        chars("a")
751                        lig("x", "")
752                        chars("-")
753                      ],
754                      post_break=[
755                        chars("b")
756                      ],
757                      replace_count=2,
758                    )
759                    chars("a")
760                    lig("c", "b")
761                "#,
762            },
763        },
764        {
765            left_boundary_char_1,
766            TestCase {
767                input: "a-b",
768                lig_kern_program: "
769                    |b -> |c^_
770                ",
771                want: r#"
772                    chars("a")
773                    disc(
774                      pre_break=[
775                        chars("-")
776                      ],
777                      post_break=[
778                        lig("c", "b", includes_left_boundary="true")
779                      ],
780                      replace_count=1,
781                    )
782                    chars("b")
783                "#,
784            },
785            lossy: true,
786        },
787        {
788            left_boundary_char_2,
789            TestCase {
790                input: "a-b",
791                lig_kern_program: "
792                    |d -> |c^_
793                ",
794                want: r#"
795                    chars("a")
796                    disc(
797                      pre_break=[
798                        chars("-")
799                      ],
800                      post_break=[],
801                      replace_count=0,
802                    )
803                    chars("b")
804                "#,
805            },
806        },
807        {
808            left_boundary_char_and_pre_break_1,
809            TestCase {
810                input: "a-b",
811                lig_kern_program: "
812                    |- -> |c^_
813                ",
814                want: r#"
815                    chars("a")
816                    disc(
817                      pre_break=[
818                        chars("-")
819                      ],
820                    )
821                    chars("b")
822                "#,
823            },
824        },
825        {
826            left_boundary_char_and_pre_break_2,
827            TestCase {
828                input: "ab-c",
829                lig_kern_program: "
830                    bc -> _z^_
831                    |b -> |d^_
832                ",
833                want: r#"
834                    chars("a")
835                    disc(
836                      pre_break=[
837                        chars("b")
838                        chars("-")
839                      ],
840                      post_break=[
841                        chars("c")
842                      ],
843                      replace_count=1,
844                    )
845                    lig("z", "bc")
846                "#,
847            },
848        },
849        {
850            pre_break_lig_kern_starts_from_separation_point,
851            TestCase {
852                input: "abc-d",
853                lig_kern_program: "
854                    ab -> ax^_
855                    xc -> _y^_
856                    yd -> _z^_
857                ",
858                want: r#"
859                    disc(
860                      pre_break=[
861                        chars("a")
862                        lig("y", "bc")
863                        chars("-")
864                      ],
865                      post_break=[
866                        chars("d")
867                      ],
868                      replace_count=2,
869                    )
870                    chars("a")
871                    lig("z", "bcd")
872                "#,
873            },
874        },
875        {
876            right_boundary_char_after_hyphen,
877            TestCase {
878                input: "a-b",
879                lig_kern_program: "
880                    -| -> -c^|
881                ",
882                want: r#"
883                    chars("a")
884                    disc(
885                      pre_break=[
886                        chars("-")
887                        lig("c", "", includes_right_boundary="true")
888                      ],
889                    )
890                    chars("b")
891                "#,
892            },
893            lossy: true,
894        },
895        {
896            big_lig_1,
897            TestCase {
898                input: "a-bc",
899                lig_kern_program: "
900                    ab -> _x^_
901                    xc -> _y^_
902                ",
903                want: r#"
904                    disc(
905                      pre_break=[
906                        chars("a")
907                        chars("-")
908                      ],
909                      post_break=[
910                        chars("b")
911                        chars("c")
912                      ],
913                      replace_count=1,
914                    )
915                    lig("y", "abc")
916                "#,
917            },
918        },
919        {
920            big_lig_2,
921            TestCase {
922                input: "a-bc",
923                lig_kern_program: "
924                    ab -> _x^_
925                    xc -> _y^_
926                    bc -> _z^_
927                ",
928                want: r#"
929                    disc(
930                      pre_break=[
931                        chars("a")
932                        chars("-")
933                      ],
934                      post_break=[
935                        lig("z", "bc")
936                      ],
937                      replace_count=1,
938                    )
939                    lig("y", "abc")
940                "#,
941            },
942        },
943        {
944            big_lig_3,
945            TestCase {
946                input: "ab-c",
947                lig_kern_program: "
948                    ab -> _x^_
949                    xc -> _y^_
950                ",
951                want: r#"
952                    disc(
953                      pre_break=[
954                        lig("x", "ab")
955                        chars("-")
956                      ],
957                      post_break=[
958                        chars("c")
959                      ],
960                      replace_count=1,
961                    )
962                    lig("y", "abc")
963                "#,
964            },
965        },
966        {
967            big_lig_4,
968            TestCase {
969                input: "ab-c",
970                lig_kern_program: "
971                    ab -> ax^_
972                ",
973                want: r#"
974                    chars("a")
975                    lig("x", "b")
976                    disc(
977                      pre_break=[
978                        chars("-")
979                      ],
980                      replace_count=0,
981                    )
982                    chars("c")
983                "#,
984            },
985        },
986        {
987            big_lig_with_hyphen,
988            TestCase {
989                input: "ab-c",
990                lig_kern_program: "
991                    ab -> ax^_
992                    x- -> xy^-
993                ",
994                want: r#"
995                    disc(
996                      pre_break=[
997                        chars("a")
998                        lig("x", "b")
999                        lig("y", "")
1000                        chars("-")
1001                      ],
1002                      replace_count=2,
1003                    )
1004                    chars("a")
1005                    lig("x", "b")
1006                    chars("c")
1007                "#,
1008            },
1009        },
1010        {
1011            big_lig_with_hyphen_2,
1012            TestCase {
1013                input: "ab-c",
1014                lig_kern_program: "
1015                    ab -> ax^b
1016                    x- -> xy^-
1017                ",
1018                want: r#"
1019                    chars("a")
1020                    lig("x", "")
1021                    chars("b")
1022                    disc(
1023                      pre_break=[
1024                        chars("-")
1025                      ],
1026                      replace_count=0,
1027                    )
1028                    chars("c")
1029                "#,
1030            },
1031        },
1032        {
1033            empty_lig_before,
1034            TestCase {
1035                input: "a-b",
1036                lig_kern_program: "
1037                    ab -> ax^b
1038                ",
1039                want: r#"
1040                    disc(
1041                      pre_break=[
1042                        chars("a")
1043                        chars("-")
1044                      ],
1045                      replace_count=2,
1046                    )
1047                    chars("a")
1048                    lig("x", "")
1049                    chars("b")
1050                "#,
1051            },
1052        },
1053        {
1054            simple_kern,
1055            TestCase {
1056                input: "a-b",
1057                lig_kern_program: "
1058                    ab -> a[100]b
1059                ",
1060                want: r#"
1061                    disc(
1062                      pre_break=[
1063                        chars("a")
1064                        chars("-")
1065                      ],
1066                      replace_count=2,
1067                    )
1068                    chars("a")
1069                    kern(0.00095pt)
1070                    chars("b")
1071                "#,
1072            },
1073        },
1074        {
1075            same_kern,
1076            TestCase {
1077                input: "a-b",
1078                lig_kern_program: "
1079                    ab -> a[100]b
1080                    a- -> a[100]-
1081                ",
1082                want: r#"
1083                    disc(
1084                      pre_break=[
1085                        chars("a")
1086                        kern(0.00095pt)
1087                        chars("-")
1088                      ],
1089                      replace_count=2,
1090                    )
1091                    chars("a")
1092                    kern(0.00095pt)
1093                    chars("b")
1094                "#,
1095            },
1096        },
1097        {
1098            synchronization_1,
1099            TestCase {
1100                input: "a-bcdefgh",
1101                lig_kern_program: "
1102                    ab -> _x^_
1103                    bc -> _y^_
1104                    cd -> _z^_
1105                    de -> _w^_
1106                    ef -> _v^_
1107                ",
1108                want: r#"
1109                    disc(
1110                      pre_break=[
1111                        chars("a-")
1112                      ],
1113                      post_break=[
1114                        lig("y", "bc")
1115                        lig("w", "de")
1116                        chars("f")
1117                      ],
1118                      replace_count=3,
1119                    )
1120                    lig("x", "ab")
1121                    lig("z", "cd")
1122                    lig("v", "ef")
1123                    # synchronization point
1124                    chars("gh", font=1)
1125                "#,
1126            },
1127        },
1128        {
1129            synchronization_2,
1130            TestCase {
1131                input: "a-bcd-ef-gh",
1132                lig_kern_program: "
1133                    ab -> _x^_
1134                    bc -> _y^_
1135                    cd -> _z^_
1136                    de -> _w^_
1137                    ef -> _v^_
1138                ",
1139                want: r#"
1140                    disc(
1141                      pre_break=[
1142                        chars("a-")
1143                      ],
1144                      post_break=[
1145                        lig("y", "bc")
1146                        lig("w", "de")
1147                        chars("f")
1148                      ],
1149                      replace_count=3,
1150                    )
1151                    lig("x", "ab")
1152                    lig("z", "cd")
1153                    # the hyphen here is skipped
1154                    lig("v", "ef")
1155                    # synchronization point
1156                    disc(
1157                      pre_break=[
1158                        chars("-")
1159                      ],
1160                      post_break=[
1161                      ],
1162                    )
1163                    chars("gh", font=1)
1164                "#,
1165            },
1166        },
1167        {
1168            synchronization_3,
1169            TestCase {
1170                input: "a-bcde",
1171                lig_kern_program: "
1172                    ab -> _x^_
1173                    bc -> _y^_
1174                    xc -> _y^_
1175                    yd -> yzd^
1176                ",
1177                want: r#"
1178                    disc(
1179                      pre_break=[
1180                        chars("a-")
1181                      ],
1182                      post_break=[
1183                        lig("y", "bc")
1184                        lig("z", "")
1185                      ],
1186                      replace_count=2,
1187                    )
1188                    lig("y", "abc")
1189                    lig("z", "")
1190                    chars("de", font=1)
1191                "#,
1192            },
1193        },
1194        {
1195            word_ends_in_comma_1,
1196            TestCase {
1197                input: "baby,",
1198                lig_kern_program: "
1199                    y, -> y[100],
1200                    y| -> y[200]|
1201                ",
1202                want: r#"
1203                    chars("baby")
1204                    kern(0.00095pt)
1205                    chars(",")
1206                "#,
1207                hyphenation_patterns: Some("baby"),
1208            },
1209        },
1210        {
1211            word_ends_in_comma_2,
1212            TestCase {
1213                input: "baby,",
1214                lig_kern_program: "
1215                    y, -> y[100],
1216                    y| -> y[200]|
1217                ",
1218                want: r#"
1219                    chars("ba")
1220                    disc(
1221                      pre_break=[
1222                        chars("-")
1223                      ],
1224                    )
1225                    chars("by")
1226                    kern(0.00095pt)
1227                    chars(",")
1228                "#,
1229                hyphenation_patterns: Some("ba-by"),
1230            },
1231        },
1232        {
1233            right_boundary_char_override_1,
1234            TestCase {
1235                input: "ba-by",
1236                lig_kern_program: "
1237                    y| -> y.^|
1238                ",
1239                want: r#"
1240                    chars("ba")
1241                    disc(
1242                      pre_break=[
1243                        chars("-")
1244                      ],
1245                    )
1246                    chars("by")
1247                    lig(".", "", includes_right_boundary="true")
1248                "#,
1249            },
1250            lossy: true,
1251        },
1252        {
1253            right_boundary_char_override_2,
1254            TestCase {
1255                input: "ab.",
1256                lig_kern_program: "
1257                    |b -> |c^_
1258                    c. -> c,^_
1259                ",
1260                want: r#"
1261                    chars("a")
1262                    disc(
1263                      pre_break=[
1264                        chars("-")
1265                      ],
1266                      post_break=[
1267                        lig("c", "b", includes_left_boundary="true")
1268                        lig(",", "", includes_right_boundary="true")
1269                      ],
1270                      replace_count=1,
1271                    )
1272                    chars("b.")
1273                "#,
1274                hyphenation_patterns: Some("a-b"),
1275            },
1276            lossy: true,
1277        },
1278        {
1279            right_boundary_char_override_3,
1280            TestCase {
1281                input: "journey.",
1282                lig_kern_program: "
1283                    y. -> y^,_
1284                    ,| -> ,?^|
1285                ",
1286                want: r#"
1287                    chars("jour")
1288                    disc(
1289                      pre_break=[
1290                        chars("-")
1291                      ],
1292                    )
1293                    chars("ney")
1294                    lig(",", "", includes_right_boundary="true")
1295                    lig(",", ".")
1296                    lig("?", "", includes_right_boundary="true")
1297                "#,
1298                hyphenation_patterns: Some(""),
1299            },
1300            lossy: true,
1301        },
1302        {
1303            right_boundary_char_override_4,
1304            TestCase {
1305                input: "journey.",
1306                lig_kern_program: "
1307                    y. -> y^,_
1308                    y, -> y^?_
1309                ",
1310                want: r#"
1311                    chars("jour")
1312                    disc(
1313                      pre_break=[
1314                        chars("-")
1315                      ],
1316                    )
1317                    chars("ney")
1318                    lig("?", "", includes_right_boundary="true")
1319                    lig("?", ".")
1320                "#,
1321                hyphenation_patterns: Some(""),
1322            },
1323            lossy: true,
1324        },
1325        {
1326            right_boundary_char_override_5,
1327            TestCase {
1328                input: "journey.",
1329                lig_kern_program: "
1330                    y. -> y,^_
1331                ",
1332                want: r#"
1333                    chars("jour")
1334                    disc(
1335                      pre_break=[
1336                        chars("-")
1337                      ],
1338                    )
1339                    chars("ney")
1340                    lig(",", "", includes_right_boundary="true")
1341                    lig(",", ".")
1342                "#,
1343                hyphenation_patterns: Some(""),
1344            },
1345            lossy: true,
1346        },
1347        {
1348            right_boundary_char_override_6,
1349            TestCase {
1350                input: "journey.",
1351                lig_kern_program: "
1352                    y. -> y^,_
1353                    y, -> y^?,
1354                ",
1355                want: r#"
1356                    chars("jour")
1357                    disc(
1358                      pre_break=[
1359                        chars("-")
1360                      ],
1361                    )
1362                    chars("ney")
1363                    lig("?", "")
1364                    lig(",", "", includes_right_boundary="true")
1365                    lig(",", ".")
1366                "#,
1367                hyphenation_patterns: Some(""),
1368            },
1369            lossy: true,
1370        },
1371        {
1372            sneezing,
1373            TestCase {
1374                input: "sneezing",
1375                lig_kern_program: "
1376                    y. -> y^,_
1377                    y, -> y^?,
1378                ",
1379                want: r#"
1380                    chars("sneez")
1381                    disc(
1382                      pre_break=[
1383                        chars("-")
1384                      ],
1385                    )
1386                    chars("ing")
1387                "#,
1388                hyphenation_patterns: Some(""),
1389                left_hyphen_min: Some(3),
1390            },
1391        },
1392        {
1393            difficult,
1394            TestCase {
1395                input: "d-if-fi-cult",
1396                lig_kern_program: "
1397                    ff -> _0^_
1398                    0i -> _1^_
1399                ",
1400                want: r#"
1401                    chars("di")
1402                    disc(
1403                      pre_break=[
1404                       chars("f-")
1405                      ],
1406                      post_break=[
1407                        chars("fi")
1408                      ],
1409                      replace_count=1,
1410                    )
1411                    lig("1", "ffi")
1412                    disc(
1413                      pre_break=[
1414                        chars("-")
1415                      ],
1416                    )
1417                    chars("cult")
1418                "#,
1419                hyphenation_patterns: Some(""),
1420                left_hyphen_min: Some(3),
1421            },
1422        },
1423    ];
1424}