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}