Skip to main content

pdfrum_doc/vt/
bidi.rs

1//! Visual reordering, over the Unicode bidirectional algorithm.
2//!
3//! One call per section, then one query per line. The paragraph direction is
4//! settable in principle but **only the automatic setting is reachable**
5//! upstream — nothing outside its own tests ever sets another — so the two
6//! forcing modes exist here for the ported assertions and carry no
7//! conformance risk.
8//!
9//! The fallback matters as much as the algorithm. A line the resolver reports
10//! zero runs for — which happens for a paragraph separator standing alone —
11//! is laid out as a single left-to-right run rather than dropped.
12
13/// Which way a paragraph runs.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Direction {
16    /// Detected from the text, defaulting to left-to-right.
17    #[default]
18    Auto,
19    /// Forced left-to-right.
20    Ltr,
21    /// Forced right-to-left.
22    Rtl,
23}
24
25impl Direction {
26    /// The paragraph level the resolver is given, or none to detect one.
27    fn level(self) -> Option<unicode_bidi::Level> {
28        match self {
29            Direction::Auto => None,
30            Direction::Ltr => Some(unicode_bidi::Level::ltr()),
31            Direction::Rtl => Some(unicode_bidi::Level::rtl()),
32        }
33    }
34}
35
36/// One visual run: where it starts in the logical order, how long it is, and
37/// which way it reads.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Run {
40    /// First logical index.
41    pub start: usize,
42    /// How many characters.
43    pub length: usize,
44    /// Whether the run reads right to left.
45    pub is_rtl: bool,
46}
47
48/// A section's resolved bidi state, queried per line.
49#[derive(Debug)]
50pub struct Resolver {
51    text: String,
52    /// Byte offset of each character, plus a final end offset, so a character
53    /// range converts to a byte range without rescanning.
54    offsets: Vec<usize>,
55    direction: Direction,
56}
57
58impl Resolver {
59    /// Resolves a section's characters.
60    #[must_use]
61    pub fn new(chars: &[u32], direction: Direction) -> Resolver {
62        let mut text = String::new();
63        let mut offsets = Vec::with_capacity(chars.len() + 1);
64        for code in chars {
65            offsets.push(text.len());
66            text.push(char::from_u32(*code).unwrap_or(char::REPLACEMENT_CHARACTER));
67        }
68        offsets.push(text.len());
69        Resolver {
70            text,
71            offsets,
72            direction,
73        }
74    }
75
76    /// The visual runs covering one line, in visual order.
77    ///
78    /// An empty result means the caller should fall back to a single
79    /// left-to-right run — which is what a line holding nothing but a
80    /// paragraph separator produces.
81    #[must_use]
82    pub fn visual_runs(&self, start: usize, length: usize) -> Vec<Run> {
83        if length == 0 || start >= self.offsets.len().saturating_sub(1) {
84            return Vec::new();
85        }
86        let end = (start + length).min(self.offsets.len() - 1);
87        let (Some(from), Some(to)) = (self.offsets.get(start), self.offsets.get(end)) else {
88            return Vec::new();
89        };
90
91        let info = unicode_bidi::BidiInfo::new(&self.text, self.direction.level());
92        let Some(paragraph) = info.paragraphs.first() else {
93            return Vec::new();
94        };
95        let (levels, ranges) = info.visual_runs(paragraph, *from..*to);
96
97        ranges
98            .into_iter()
99            .filter_map(|range| {
100                let run_start = self.char_index(range.start)?;
101                let run_end = self.char_index(range.end)?;
102                let is_rtl = levels
103                    .get(range.start)
104                    .is_some_and(unicode_bidi::Level::is_rtl);
105                (run_end > run_start).then_some(Run {
106                    start: run_start,
107                    length: run_end - run_start,
108                    is_rtl,
109                })
110            })
111            .collect()
112    }
113
114    /// The character index a byte offset begins.
115    fn char_index(&self, byte: usize) -> Option<usize> {
116        self.offsets.iter().position(|offset| *offset == byte)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::{Direction, Resolver};
123
124    /// The logical indices in visual order, which is what the ported
125    /// assertions are written as.
126    fn order(text: &str, direction: Direction) -> Vec<usize> {
127        let chars: Vec<u32> = text.chars().map(|c| c as u32).collect();
128        let resolver = Resolver::new(&chars, direction);
129        let mut out = Vec::new();
130        for run in resolver.visual_runs(0, chars.len()) {
131            let span = run.start..run.start + run.length;
132            if run.is_rtl {
133                out.extend(span.rev());
134            } else {
135                out.extend(span);
136            }
137        }
138        out
139    }
140
141    // The six orderings pinned upstream, over a Latin run and a Hebrew one
142    // separated by underscores.
143    const LATIN_FIRST: &str = "A_B_C_\u{5D0}_\u{5D1}";
144    const HEBREW_FIRST: &str = "\u{5D0}_\u{5D1}_\u{5D2}_A_B";
145
146    #[test]
147    fn latin_first_reads_left_to_right_until_the_hebrew_run() {
148        assert_eq!(
149            order(LATIN_FIRST, Direction::Auto),
150            [0, 1, 2, 3, 4, 5, 8, 7, 6]
151        );
152        assert_eq!(
153            order(LATIN_FIRST, Direction::Ltr),
154            [0, 1, 2, 3, 4, 5, 8, 7, 6]
155        );
156        assert_eq!(
157            order(LATIN_FIRST, Direction::Rtl),
158            [8, 7, 6, 5, 0, 1, 2, 3, 4]
159        );
160    }
161
162    #[test]
163    fn hebrew_first_puts_the_whole_paragraph_the_other_way_round() {
164        assert_eq!(
165            order(HEBREW_FIRST, Direction::Auto),
166            [6, 7, 8, 5, 4, 3, 2, 1, 0]
167        );
168        assert_eq!(
169            order(HEBREW_FIRST, Direction::Ltr),
170            [4, 3, 2, 1, 0, 5, 6, 7, 8]
171        );
172        assert_eq!(
173            order(HEBREW_FIRST, Direction::Rtl),
174            [6, 7, 8, 5, 4, 3, 2, 1, 0]
175        );
176    }
177
178    #[test]
179    fn an_empty_line_reports_no_runs_at_all() {
180        let resolver = Resolver::new(&[], Direction::Auto);
181        assert!(resolver.visual_runs(0, 0).is_empty());
182        let resolver = Resolver::new(&[65], Direction::Auto);
183        assert!(resolver.visual_runs(0, 0).is_empty());
184        assert!(resolver.visual_runs(4, 2).is_empty());
185    }
186}