hyphenate/
lib.rs

1//! Implementation of TeX's hyphenation algorithm.
2//!
3//! The main entry point is [`Hyphenator`], which can be constructed with
4//! plain TeX's built-in English patterns using [`Hyphenator::plain_tex_en_us`].
5
6/// Hyphenates words using TeX's pattern-matching algorithm (Knuth-Liang).
7///
8/// Construct with [`Hyphenator::plain_tex_en_us`] for the standard plain TeX
9/// English patterns, or load custom patterns with [`Hyphenator::load_patterns`].
10#[derive(Default)]
11pub struct Hyphenator {
12    // The data u8s have the following meaning.
13    // The bottom four bits (u8 % 16) contain the op code. For 0..=9, this says
14    // emit this score. 10 and above means: terminate.
15    // The top four bits (u8 / 16) is the number of zero scores to emit before
16    // performing the operation above.
17    data: Vec<u8>,
18    patterns: trie::Trie,
19}
20
21/// Implementations of this trait can get the lower case character of a character.
22pub trait LowerCaser {
23    /// Return the lower case character of the provided character, of [`None`] if the character
24    /// does not have a lower case character.
25    fn to_lower_case(&self, c: char) -> Option<char>;
26}
27
28/// The ASCII implementation of [`LowerCaser`] returns the lower case letter for all ASCII
29/// alphabetic characters, and [`None`] for all other characters.
30#[derive(Default)]
31pub struct AsciiLowerCaser {}
32
33impl LowerCaser for AsciiLowerCaser {
34    fn to_lower_case(&self, c: char) -> Option<char> {
35        if c.is_ascii_alphabetic() {
36            Some(c.to_ascii_lowercase())
37        } else {
38            None
39        }
40    }
41}
42
43impl Hyphenator {
44    /// Construct a hyphenator loaded with plain TeX's English (US) patterns and exceptions.
45    pub fn plain_tex_en_us() -> Self {
46        let patterns = include_str!("plain_tex_patterns.txt");
47        let exceptions = include_str!("plain_tex_exceptions.txt");
48        let mut h: Self = Default::default();
49        h.load_patterns(patterns);
50        h.insert_exceptions(exceptions);
51        h
52    }
53    /// Load hyphenation patterns from a whitespace-separated string in TeX pattern format.
54    pub fn load_patterns(&mut self, patterns: &str) {
55        // TeX.2021.961 and onwards.
56        let mut empty_value: Option<trie::Value> = None;
57        for pattern in patterns.split_whitespace() {
58            let mut vertex = self.patterns.root();
59            let mut value = &mut empty_value;
60            if pattern.starts_with('.') {
61                (vertex, value) = self.patterns.next(vertex, trie::Edge::StartOfWord);
62            }
63            let data_start = self.data.len();
64            enum State {
65                // The payload is the number of characters before this character
66                // that were not assigned a score. If we write out a score for this
67                // character, we will first write out zero scores for the other
68                // characters. This is a serialization optimization.
69                AfterChar(usize),
70                AfterScore,
71            }
72            let mut state = State::AfterChar(0);
73            for c in pattern.chars() {
74                match c {
75                    '0'..='9' => {
76                        let num_zeros: u8 = match state {
77                            State::AfterChar(mut n) => {
78                                while let Some(m) = n.checked_sub(16) {
79                                    // This outputs 15 in the high bits and 0 in the low bits.
80                                    // We will then output 15 0 scores from the high bits and 1 0 score from the
81                                    // low bits, so 16 0s in total.
82                                    self.data.push(15 * 16);
83                                    n = m;
84                                }
85                                n.try_into().expect("n<16 which fits in u8")
86                            }
87                            State::AfterScore => {
88                                // In the case of two consecutive scores (e.g. a123b)
89                                // it seems the last one wins. See. TeX.2021.962.
90                                self.data.pop();
91                                0
92                            }
93                        };
94                        let op: u8 = (c as u32 - '0' as u32).try_into().expect("digits are <= 9");
95                        self.data.push(op + num_zeros * 16);
96                        state = State::AfterScore;
97                    }
98                    '.' => {
99                        // Already handled above.
100                        continue;
101                    }
102                    _ => {
103                        // TODO: we should error if it's not a valid pattern like
104                        // // the `help1` in TeX.2021.962.
105                        (vertex, value) = self.patterns.next(vertex, trie::Edge::Char(c));
106                        state = State::AfterChar(match state {
107                            State::AfterChar(n) => n + 1,
108                            State::AfterScore => 0,
109                        });
110                    }
111                }
112            }
113            let terminal_op = if pattern.ends_with('.') {
114                (vertex, value) = self.patterns.next(vertex, trie::Edge::EndOfWord);
115                11
116            } else {
117                10
118            };
119            let num_zeros: u8 = match state {
120                State::AfterChar(mut n) => {
121                    while let Some(m) = n.checked_sub(16) {
122                        // This outputs 15 in the high bits and 0 in the low bits.
123                        // We will then output 15 0 scores from the high bits and 1 0 score from the
124                        // low bits, so 16 0s in total.
125                        self.data.push(15 * 16);
126                        n = m;
127                    }
128                    n.try_into().expect("n<16 which fits in u8")
129                }
130                State::AfterScore => 0,
131            };
132            self.data.push(terminal_op + num_zeros * 16);
133            *value = Some(trie::Value(data_start));
134        }
135    }
136    /// Add multiple hyphenation exceptions. These are separate words separated by whitespace, with
137    /// each word satisfying the format in [`Self::insert_exception`].
138    pub fn insert_exceptions(&mut self, hyphenated_words: &str) {
139        hyphenated_words
140            .lines()
141            .map(|l| l.trim())
142            .filter(|l| !l.is_empty())
143            .for_each(|l| {
144                self.insert_exception(l);
145            });
146    }
147    /// Add a hyphenation exception. The word is given with hyphens marking the allowed break points,
148    /// e.g. `"hy-phen-ation"`.
149    pub fn insert_exception(&mut self, hyphenated_word: &str) {
150        let mut vertex = self.patterns.root();
151        vertex = self.patterns.next(vertex, trie::Edge::StartOfWord).0;
152        let data_start = self.data.len();
153        let mut word = String::new();
154        let mut indices = vec![0];
155        self.data.push(6);
156        for c in hyphenated_word.chars() {
157            if c == '-' {
158                indices.pop();
159                indices.push(7);
160                self.data.pop();
161                self.data.push(7);
162            } else {
163                vertex = self.patterns.next(vertex, trie::Edge::Char(c)).0;
164                word.push(c);
165                indices.push(6);
166                self.data.push(6);
167            }
168        }
169        self.data.push(10);
170        let value = self.patterns.next(vertex, trie::Edge::EndOfWord).1;
171        *value = Some(trie::Value(data_start));
172    }
173    /// Hyphenate a word, returning it with `-` inserted at each valid break point.
174    pub fn hypthenate<L: LowerCaser>(&self, lower_caser: &L, word: &str, target: &mut String) {
175        let mut indices = self.calculate_indices(lower_caser, word);
176        let mut next = indices.next();
177        for (i, c) in word.chars().enumerate() {
178            if next == Some(i) {
179                target.push('-');
180                next = indices.next();
181            }
182            target.push(c);
183        }
184    }
185
186    /// Return the set of character indices before which a hyphen may be inserted.
187    pub fn calculate_indices<L: LowerCaser>(
188        &self,
189        lower_caser: &L,
190        word: &str,
191    ) -> impl Iterator<Item = usize> {
192        self.calculate_aggregate_scores(lower_caser, word)
193            .into_iter()
194            .enumerate()
195            .filter(|(_, score)| *score % 2 != 0)
196            .map(|(i, _)| i)
197    }
198    fn calculate_aggregate_scores<L: LowerCaser>(&self, lower_caser: &L, word: &str) -> Vec<u8> {
199        let mut scores = vec![0_u8; word.len() + 1];
200        self.for_each_pattern(lower_caser, word, |p| {
201            let mut k = 0;
202            for op in p.data {
203                let num_zeros = op / 16;
204                k += (num_zeros) as usize;
205                let op = op % 16;
206                match op {
207                    score @ ..10 => {
208                        if scores[p.offset + k] < score {
209                            scores[p.offset + k] = score;
210                        }
211                        k += 1;
212                    }
213                    _ => {
214                        break;
215                    }
216                }
217            }
218        });
219        let num_chars = word.chars().count();
220        // Never hyphenate before the word.
221        scores[0] = 0;
222        // Never hyphenate after the word.
223        scores.truncate(num_chars);
224        scores
225    }
226
227    /// Return the set of character indices before which a hyphen may be inserted.
228    pub fn calculate_explanation<L: LowerCaser>(&self, lower_caser: &L, word: &str) -> Explanation {
229        let lower_cased: String = word
230            .chars()
231            .map(|c| lower_caser.to_lower_case(c).unwrap_or(c))
232            .collect();
233        // let mut total_scores = vec![0_u8; word.len() + 1];
234        let mut patterns: Vec<MatchedPattern> = vec![];
235        self.for_each_pattern(lower_caser, word, |p| {
236            let mut scores: Vec<u8> = vec![];
237            let mut it = p.data.iter();
238            let end_of_word = loop {
239                let Some(op) = it.next() else { break false };
240                let num_zeros = op / 16;
241                scores.resize(scores.len() + num_zeros as usize, 0_u8);
242                let op = op % 16;
243                match op {
244                    score @ ..10 => {
245                        scores.push(score);
246                    }
247                    _ => {
248                        break op == 11;
249                    }
250                }
251            };
252            patterns.push(MatchedPattern {
253                start_of_word: p.start_of_word,
254                offset: p.offset,
255                chars: lower_cased
256                    .chars()
257                    .skip(p.offset)
258                    .take(p.num_chars)
259                    .collect(),
260                end_of_word,
261                scores,
262            });
263        });
264        Explanation {
265            lower_cased,
266            patterns,
267            aggregate_scores: self.calculate_aggregate_scores(lower_caser, word),
268        }
269    }
270
271    /// Runs the provided closure for every matching pattern.
272    ///
273    /// The arguments to the cluster are: the operations for the pattern, whether the
274    /// pattern is for the start of the word only, and the offset within the word
275    /// that the pattern starts.
276    fn for_each_pattern<L: LowerCaser, F: FnMut(Pattern)>(
277        &self,
278        lower_caser: &L,
279        word: &str,
280        mut for_each: F,
281    ) {
282        let mut process =
283            |mut vertex: trie::Vertex, start_of_word: bool, lower: usize, lower_chars: usize| {
284                let mut chars = word[lower..].chars().map(|c| lower_caser.to_lower_case(c));
285                let mut num_chars = 0_usize;
286                loop {
287                    num_chars += 1;
288                    let edge = match chars.next() {
289                        None => trie::Edge::EndOfWord,
290                        Some(None) => {
291                            // Some(None) occurs when the words contains a non-letter character.
292                            // In this case we stop trying to hyphenate.
293                            return;
294                        }
295                        Some(Some(c)) => trie::Edge::Char(c),
296                    };
297                    let pattern;
298                    (vertex, pattern) = match self.patterns.next_or(vertex, edge) {
299                        None => return,
300                        Some(entry) => entry,
301                    };
302                    let Some(pattern) = pattern else {
303                        continue;
304                    };
305                    for_each(Pattern {
306                        start_of_word,
307                        offset: lower_chars,
308                        num_chars,
309                        data: &self.data[pattern.0..],
310                    });
311                }
312            };
313        if let Some((vertex, _)) = self
314            .patterns
315            .next_or(self.patterns.root(), trie::Edge::StartOfWord)
316        {
317            process(vertex, true, 0, 0);
318        }
319        let mut lower_chars: usize = 0;
320        let mut lower = 0;
321        while let Some(c) = word[lower..].chars().next() {
322            if lower_caser.to_lower_case(c).is_none() {
323                break;
324            }
325            process(self.patterns.root(), false, lower, lower_chars);
326            lower += c.len_utf8();
327            lower_chars += 1;
328        }
329    }
330}
331
332struct Pattern<'a> {
333    start_of_word: bool,
334    offset: usize,
335    num_chars: usize,
336    data: &'a [u8],
337}
338
339/// Remove all `-` characters from a word.
340pub fn strip_hyphens(word: &str) -> String {
341    word.chars().filter(|c| *c != '-').collect()
342}
343
344/// Explanation of why a word is hyphenated the way it is.
345#[derive(Debug, PartialEq)]
346pub struct Explanation {
347    /// Lower cased word.
348    pub lower_cased: String,
349    /// All patterns that matched the word.
350    pub patterns: Vec<MatchedPattern>,
351    /// Aggregate scores among all scores for matching patterns.
352    pub aggregate_scores: Vec<u8>,
353}
354
355impl Explanation {
356    /// Whether this hyphenation explanation is "interesting"
357    ///
358    /// A hyphenation is interesting if there is at least one hyphen
359    /// and there is at least one position where there are competing patterns
360    /// (i.e. one pattern says to hyphenate and another says not to).
361    pub fn is_interesting(&self) -> bool {
362        // Check for a hyphen
363        if !self.aggregate_scores.iter().any(|score| score % 2 == 1) {
364            return false;
365        }
366        // Check for an inhibitor
367        self.patterns.iter().any(|p| {
368            p.scores
369                .iter()
370                .zip(self.aggregate_scores[p.offset..].iter())
371                .any(|(&pattern_score, &aggregate_score)| {
372                    // If this pattern says there should be a hyphen but there
373                    // isn't, another pattern must inhibit the hyphen.
374                    // Hence an inhibitor exists.
375                    pattern_score > 0
376                        && aggregate_score > 0
377                        && pattern_score % 2 != aggregate_score % 2
378                    // pattern_score % 2 == 1 && aggregate_score > 0 && aggregate_score % 2 == 0
379                })
380        })
381    }
382}
383
384impl std::fmt::Display for Explanation {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        let n = self.lower_cased.chars().count();
387        write!(f, ".")?;
388        for c in self.lower_cased.chars() {
389            write!(f, " {}", c)?;
390        }
391        writeln!(f, " .")?;
392        for _ in 0..n {
393            write!(f, "--")?;
394        }
395        writeln!(f, "---")?;
396        for pattern in &self.patterns {
397            writeln!(f, "{pattern}")?;
398        }
399        for _ in 0..n {
400            write!(f, "--")?;
401        }
402        writeln!(f, "---")?;
403        for score in &self.aggregate_scores {
404            write!(f, " {score}")?;
405        }
406        writeln!(f)?;
407        for score in &self.aggregate_scores {
408            write!(f, " {}", if score % 2 == 0 { ' ' } else { '-' })?;
409        }
410        writeln!(f)?;
411        write!(f, ".")?;
412        for (i, c) in self.lower_cased.chars().enumerate() {
413            write!(
414                f,
415                "{}{}",
416                if self.aggregate_scores.get(i).unwrap_or(&0) % 2 == 0 {
417                    ' '
418                } else {
419                    '-'
420                },
421                c
422            )?;
423        }
424        writeln!(f, " .")?;
425        Ok(())
426    }
427}
428
429/// Pattern matched when performing hyphenation.
430#[derive(Debug, PartialEq)]
431pub struct MatchedPattern {
432    /// Whether this pattern only matches starts of words.
433    pub start_of_word: bool,
434    /// Offset in the word, in characters,that the pattern starts.
435    pub offset: usize,
436    /// Characters in the pattern.
437    pub chars: String,
438    /// Whether this pattern only matches ends of words.
439    pub end_of_word: bool,
440    /// Scores for this pattern.
441    pub scores: Vec<u8>,
442}
443
444impl std::fmt::Display for MatchedPattern {
445    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446        write!(f, "{}", if self.start_of_word { '.' } else { ' ' })?;
447        for _ in 0..self.offset {
448            write!(f, "  ")?;
449        }
450        let mut chars = self.chars.chars();
451        for score in &self.scores {
452            if *score == 0 {
453                write!(f, " {}", chars.next().unwrap_or(' '))?;
454            } else {
455                write!(f, "{}{}", score, chars.next().unwrap_or(' '))?;
456            };
457        }
458        if self.end_of_word {
459            write!(f, " .")?;
460        }
461        Ok(())
462    }
463}
464
465mod trie {
466    use std::collections::HashMap;
467
468    #[derive(Debug, Default)]
469    pub struct Trie {
470        m: HashMap<(Vertex, Edge), (Vertex, Option<Value>)>,
471        next_vertex: Vertex,
472    }
473
474    impl Trie {
475        pub fn root(&self) -> Vertex {
476            Vertex(u32::MAX)
477        }
478        pub fn next_or(&self, current: Vertex, edge: Edge) -> Option<(Vertex, Option<Value>)> {
479            self.m.get(&(current, edge)).copied()
480        }
481        pub fn next(&mut self, current: Vertex, edge: Edge) -> (Vertex, &mut Option<Value>) {
482            let (a, b) = self.m.entry((current, edge)).or_insert_with(|| {
483                let next = self.next_vertex;
484                self.next_vertex = Vertex(self.next_vertex.0 + 1);
485                (next, None)
486            });
487            (*a, b)
488        }
489    }
490
491    #[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy)]
492    pub struct Vertex(u32);
493
494    #[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
495    pub enum Edge {
496        StartOfWord,
497        Char(char),
498        EndOfWord,
499    }
500
501    #[derive(Debug, Clone, Copy)]
502    pub struct Value(pub usize);
503}
504
505#[cfg(test)]
506mod test {
507    use super::*;
508    use pretty_assertions::assert_eq;
509
510    macro_rules! hyphenation_tests {
511        ( $( $name:ident => $expected:literal, )* ) => {
512            $(
513                #[test]
514                #[allow(non_snake_case)]
515                fn $name() {
516                    let hyphenator = Hyphenator::plain_tex_en_us();
517                    let mut got = String::new();
518                    let lower_caser: AsciiLowerCaser = Default::default();
519                    hyphenator.hypthenate( &lower_caser, stringify!($name), &mut got);
520                    assert_eq!(got, $expected);
521                }
522            )*
523        };
524    }
525
526    hyphenation_tests!(
527        // From the TeXBook.
528        // But the TeXBook assumes that \leftminhyphen=\rightminhyphen=3 so the results are a little different.
529        record => "record",
530        hyphenation => "hy-phen-ation",
531        concatenation => "con-cate-na-tion",
532        supercalifragilisticexpialidocious => "su-per-cal-ifrag-ilis-tic-ex-pi-ali-do-cious",
533        bachelor => "bach-e-lor",
534        echelon => "ech-e-lon",
535        toothaches => "toothaches",
536        campfire => "camp-fire",
537        biorhythm => "biorhyth-m",
538        algorithm => "al-go-rith-m",
539        pneumonoultramicroscopicsilicovolcanoconiosis => "p-neu-monoul-tra-mi-cro-scop-ic-sil-i-co-vol-canoco-nio-sis",
540        project => "project",
541        present => "present",
542        table => "ta-ble",
543        Table => "Ta-ble",
544        // From running the hyphenator over /usr/share/dict/words on Mac
545        ach => "ach",
546        Aaronic => "Aa-ron-ic",
547        Abelia => "A-beli-a",
548        William => "William",
549        chaffless => "chaf-f-less",
550    );
551
552    macro_rules! explanation_tests {
553        ( $( $name:ident => ($expected:expr, $want_explain: expr,), )* ) => {
554            $(
555                #[test]
556                #[allow(non_snake_case)]
557                fn $name() {
558                    let hyphenator = Hyphenator::plain_tex_en_us();
559                    let lower_caser: AsciiLowerCaser = Default::default();
560                    let got = hyphenator.calculate_explanation( &lower_caser, stringify!($name));
561                    assert_eq!(got, $expected);
562                    assert_eq!(format!["{}", got], $want_explain);
563                }
564            )*
565        };
566    }
567
568    explanation_tests!(
569            DifFicult => (Explanation{
570                lower_cased: "difficult".into(),
571                patterns: vec![
572                    // d1if
573                    MatchedPattern{
574                        start_of_word: false,
575                        offset: 0,
576                        chars: "dif".into(),
577                        end_of_word: false,
578                        scores: vec![0, 1, 0],
579                   },
580                   // 4f1f
581                   MatchedPattern{
582                       start_of_word: false,
583                       offset: 2,
584                       chars: "ff".into(),
585                       end_of_word: false,
586                       scores: vec![4, 1],
587                  },
588                   // 1fi
589                   MatchedPattern{
590                       start_of_word: false,
591                       offset: 3,
592                       chars: "fi".into(),
593                       end_of_word: false,
594                       scores: vec![1, 0],
595                  },
596                   // fi3cu
597                   MatchedPattern{
598                       start_of_word: false,
599                       offset: 3,
600                       chars: "ficu".into(),
601                       end_of_word: false,
602                       scores: vec![0, 0, 3, 0],
603                  },
604                   // 4lt
605                   MatchedPattern{
606                       start_of_word: false,
607                       offset: 7,
608                       chars: "lt".into(),
609                       end_of_word: false,
610                       scores: vec![4, 0],
611                  },
612                ],
613                aggregate_scores: vec![0, 1, 4, 1, 0, 3, 0, 4, 0],
614            },
615". d i f f i c u l t .
616---------------------
617  d1i f
618     4f1f
619       1f i
620        f i3c u
621               4l t
622---------------------
623 0 1 4 1 0 3 0 4 0
624   -   -   -      
625. d-i f-f i-c u l t .
626",
627            ),
628            cove => (Explanation{
629                lower_cased: "cove".into(),
630                patterns: vec![
631                    // 1co
632                    MatchedPattern{
633                        start_of_word: false,
634                        offset: 0,
635                        chars: "co".into(),
636                        end_of_word: false,
637                        scores: vec![1, 0],
638                    },
639                    // cov1
640                    MatchedPattern{
641                        start_of_word: false,
642                        offset: 0,
643                        chars: "cov".into(),
644                        end_of_word: false,
645                        scores: vec![0, 0, 0, 1],
646                    },
647                    // cove4
648                    MatchedPattern{
649                        start_of_word: false,
650                        offset: 0,
651                        chars: "cove".into(),
652                        end_of_word: false,
653                        scores: vec![0, 0, 0, 0, 4],
654                    },
655                    // 4ve.
656                    MatchedPattern{
657                        start_of_word: false,
658                        offset: 2,
659                        chars: "ve".into(),
660                        end_of_word: true,
661                        scores: vec![4, 0],
662                    },
663                ],
664                aggregate_scores: vec![0, 0, 4, 1],
665            },
666". c o v e .
667-----------
668 1c o
669  c o v1 
670  c o v e4 
671     4v e .
672-----------
673 0 0 4 1
674       -
675. c o v-e .
676",
677            ),
678            antce => (Explanation{
679                lower_cased: "antce".into(),
680                patterns: vec![
681                    // .ant4
682                    MatchedPattern{
683                        start_of_word: true,
684                        offset: 0,
685                        chars: "ant".into(),
686                        end_of_word: false,
687                        scores: vec![0, 0, 0, 4],
688                    },
689                    // a2n
690                    MatchedPattern{
691                        start_of_word: false,
692                        offset: 0,
693                        chars: "an".into(),
694                        end_of_word: false,
695                        scores: vec![0, 2],
696                    },
697                    // n1t
698                    MatchedPattern{
699                        start_of_word: false,
700                        offset: 1,
701                        chars: "nt".into(),
702                        end_of_word: false,
703                        scores: vec![0, 1],
704                    },
705                    // 4tc
706                    MatchedPattern{
707                        start_of_word: false,
708                        offset: 2,
709                        chars: "tc".into(),
710                        end_of_word: false,
711                        scores: vec![4, 0],
712                    },
713                    // 2ce.
714                    MatchedPattern{
715                        start_of_word: false,
716                        offset: 3,
717                        chars: "ce".into(),
718                        end_of_word: true,
719                        scores: vec![2, 0],
720                    },
721                ],
722                aggregate_scores: vec![0, 2, 4, 4, 0],
723            },
724". a n t c e .
725-------------
726. a n t4 
727  a2n
728    n1t
729     4t c
730       2c e .
731-------------
732 0 2 4 4 0
733          
734. a n t c e .
735",
736    ),
737    );
738}