Skip to main content

oxideav_scribe/
layout.rs

1//! Single-line measurement + word-wrap helpers for round-1.
2//!
3//! No bidi, no mixed-script reordering — just enough machinery to slice
4//! a UTF-8 string into "lines that fit `max_width`" by breaking at
5//! whitespace boundaries (or, if a single word overflows, mid-word).
6//!
7//! The shaper is invoked once per candidate line so kerning and
8//! ligatures are correctly accounted for in the width budget.
9
10use crate::bidi::{
11    apply_mirroring, bidi_class, process_paragraph_classes_with_brackets, reorder_combining_marks,
12    reorder_line, reset_trailing_levels, BidiClass,
13};
14use crate::face::Face;
15use crate::shaper::{PositionedGlyph, Shaper};
16use crate::Error;
17
18/// Width of a shaped run in raster pixels: cumulative advance + the
19/// trailing glyph's offset (which is normally 0; included for correctness
20/// when round-2 mark-to-base attachment lands).
21pub fn run_width(glyphs: &[PositionedGlyph]) -> f32 {
22    let mut w = 0.0;
23    for g in glyphs {
24        w += g.x_advance + g.x_offset;
25    }
26    w
27}
28
29/// The visual-order result of driving the full UAX #9 §3 + §3.4
30/// bidirectional pipeline over one display line.
31///
32/// A renderer that wants correct bidirectional text walks
33/// [`VisualLine::visual`] left-to-right (the natural rendering
34/// direction of the output device), feeding each character to the
35/// shaper / cmap and laying the glyphs out in increasing x. The
36/// per-character permutation [`VisualLine::logical_to_visual`] and its
37/// inverse [`VisualLine::visual_to_logical`] let the caller map a
38/// visual glyph back to its source character (cursor hit-testing,
39/// selection-rectangle building) and vice versa.
40///
41/// `visual` already has rule **L4** mirroring applied — every
42/// character whose resolved level is odd (right-to-left) and that has
43/// a `Bidi_Mirroring_Glyph` pair (a bracket, an angle quotation mark,
44/// a mathematical relation, …) is the mirrored code point, not the
45/// logical one — so the renderer must *not* mirror again.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct VisualLine {
48    /// The line's characters in left-to-right visual order, with L4
49    /// mirroring applied. `visual.len()` equals the line's character
50    /// count.
51    pub visual: Vec<char>,
52    /// Permutation entry `k` is the logical index of the character
53    /// that belongs at visual position `k` (the UAX #9 §3.4 L2
54    /// output). `visual[k]` is the L4-mirrored form of the logical
55    /// character at index `logical_to_visual[k]`.
56    pub logical_to_visual: Vec<usize>,
57    /// The inverse permutation: entry `i` is the visual position of
58    /// the character whose logical index is `i`. Equivalent to
59    /// inverting [`Self::logical_to_visual`]; precomputed for
60    /// O(1) logical-to-visual hit-testing.
61    pub visual_to_logical: Vec<usize>,
62    /// The paragraph embedding level resolved by P2 / P3 (or supplied
63    /// by the caller as the HL1 override). `0` for an LTR line, `1`
64    /// for an RTL line.
65    pub base_level: u8,
66}
67
68impl VisualLine {
69    /// Collect [`Self::visual`] into a `String` — the line in the
70    /// order a left-to-right renderer paints it.
71    #[must_use]
72    pub fn to_visual_string(&self) -> String {
73        self.visual.iter().collect()
74    }
75
76    /// Number of characters in the line.
77    #[must_use]
78    pub fn len(&self) -> usize {
79        self.visual.len()
80    }
81
82    /// Whether the line is empty.
83    #[must_use]
84    pub fn is_empty(&self) -> bool {
85        self.visual.is_empty()
86    }
87}
88
89/// Drive the complete UAX #9 bidirectional pipeline over a single
90/// **display line** and return its characters in left-to-right visual
91/// order.
92///
93/// This is the high-level bridge the [`crate::bidi`] module's per-rule entry
94/// points compose into: a caller that has already decided where the
95/// paragraph breaks into lines (e.g. via [`wrap_lines`]) passes one
96/// line here and receives a [`VisualLine`] whose `visual` field is
97/// ready to feed glyph-by-glyph into the shaper in rendering order.
98///
99/// The pipeline run is, per line:
100///
101/// 1. **§3.2** class assignment + **§3.3 P → X → W → N0 → N1 / N2 →
102///    I** via [`process_paragraph_classes_with_brackets`] (the
103///    bracket-aware variant, so paired brackets resolve per N0).
104/// 2. **§3.4 L1** trailing-whitespace / separator level reset via
105///    [`reset_trailing_levels`] over the whole line.
106/// 3. **§3.4 L2** the logical-to-visual permutation via
107///    [`reorder_line`].
108/// 4. **§3.4 L3** combining-mark reordering via
109///    [`reorder_combining_marks`], so a base + its marks stay in
110///    `base, mark, …` order after the RTL reversal (the contract a
111///    renderer that paints marks after the base needs).
112/// 5. **§3.4 L4** mirroring via [`apply_mirroring`], applied to the
113///    logical characters at their resolved levels, then projected
114///    through the L2 / L3 permutation into `visual`.
115///
116/// `base_level` is the HL1 higher-level-protocol override: `Some(0)`
117/// forces an LTR line, `Some(1)` forces RTL, and `None` lets P2 / P3
118/// resolve the base from the line's first strong character.
119///
120/// A line should be a single paragraph's worth of text (no `B`
121/// paragraph separator in the middle); callers split on paragraph
122/// separators with [`crate::bidi::split_paragraphs`] before line-breaking.
123/// An embedded trailing `B` is handled by L1 like any other line.
124///
125/// Provenance: composed from the §3 / §3.4 per-rule entry points in
126/// the [`crate::bidi`] module, each of which cites
127/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html`.
128///
129/// # Examples
130///
131/// ```
132/// use oxideav_scribe::layout::reorder_line_visual;
133///
134/// // Pure LTR: visual order equals logical order.
135/// let line = reorder_line_visual("abc", None);
136/// assert_eq!(line.base_level, 0);
137/// assert_eq!(line.to_visual_string(), "abc");
138/// assert_eq!(line.logical_to_visual, vec![0, 1, 2]);
139/// ```
140#[must_use]
141pub fn reorder_line_visual(text: &str, base_level: Option<u8>) -> VisualLine {
142    let chars: Vec<char> = text.chars().collect();
143    let classes: Vec<BidiClass> = chars.iter().copied().map(bidi_class).collect();
144
145    let carrier = process_paragraph_classes_with_brackets(&classes, &chars, base_level);
146
147    // §3.4 L1: reset segment / paragraph separators + trailing
148    // whitespace runs to the paragraph level, using the *original*
149    // classes per the §3.4 normative note. Work on a clone so the
150    // resolved levels used for L4 mirroring stay intact.
151    let mut line_levels = carrier.levels.clone();
152    reset_trailing_levels(&carrier.classes, &mut line_levels, carrier.paragraph_level);
153
154    // §3.4 L2: the logical-to-visual permutation.
155    let mut logical_to_visual = reorder_line(&line_levels);
156
157    // §3.4 L3: keep each base + its combining marks in base-first
158    // order after the RTL reversal.
159    reorder_combining_marks(&carrier.classes, &line_levels, &mut logical_to_visual);
160
161    // §3.4 L4: mirror odd-resolved-level characters in logical order,
162    // then project through the permutation. Mirroring keys off the
163    // resolved (post-I) levels, not the L1-reset levels, so a trailing
164    // mirrored bracket inside reset whitespace still mirrors correctly.
165    let mut mirrored = chars.clone();
166    apply_mirroring(&mut mirrored, &carrier.levels);
167
168    let n = chars.len();
169    let mut visual = Vec::with_capacity(n);
170    let mut visual_to_logical = vec![0usize; n];
171    for (vis_pos, &log_idx) in logical_to_visual.iter().enumerate() {
172        visual.push(mirrored[log_idx]);
173        visual_to_logical[log_idx] = vis_pos;
174    }
175
176    VisualLine {
177        visual,
178        logical_to_visual,
179        visual_to_logical,
180        base_level: carrier.paragraph_level,
181    }
182}
183
184/// Break `text` into lines that fit within `max_width` after shaping.
185/// Whitespace runs are the preferred break points; a single word that
186/// is wider than `max_width` is broken character-by-character so the
187/// caller never receives an over-wide line.
188///
189/// Returns the line strings (not their shaped output) — the caller
190/// usually feeds each line back into [`Shaper::shape`] for the final
191/// composition step.
192pub fn wrap_lines(
193    face: &Face,
194    text: &str,
195    size_px: f32,
196    max_width: f32,
197) -> Result<Vec<String>, Error> {
198    if text.is_empty() {
199        return Ok(Vec::new());
200    }
201    if max_width <= 0.0 {
202        // Caller didn't constrain width — return one line per actual
203        // newline (collapsing them is wrong; preserving them is the
204        // least-surprise default).
205        return Ok(text.split('\n').map(|s| s.to_string()).collect());
206    }
207
208    let mut lines: Vec<String> = Vec::new();
209    for paragraph in text.split('\n') {
210        wrap_paragraph(face, paragraph, size_px, max_width, &mut lines)?;
211    }
212    Ok(lines)
213}
214
215fn wrap_paragraph(
216    face: &Face,
217    text: &str,
218    size_px: f32,
219    max_width: f32,
220    lines: &mut Vec<String>,
221) -> Result<(), Error> {
222    if text.is_empty() {
223        lines.push(String::new());
224        return Ok(());
225    }
226
227    // Tokenise on whitespace, keeping the spaces attached to the
228    // following word so the trailing-space behaviour is consistent.
229    let words: Vec<String> = split_keeping_whitespace(text);
230    if words.is_empty() {
231        lines.push(text.to_string());
232        return Ok(());
233    }
234
235    let mut current = String::new();
236    for word in words {
237        let candidate = if current.is_empty() {
238            word.trim_start().to_string()
239        } else {
240            format!("{current}{word}")
241        };
242        let glyphs = Shaper::shape(face, &candidate, size_px)?;
243        if run_width(&glyphs) <= max_width || current.is_empty() {
244            current = candidate;
245            // If even the first word doesn't fit, hard-break it.
246            let cur_glyphs = Shaper::shape(face, &current, size_px)?;
247            if run_width(&cur_glyphs) > max_width {
248                let (head, tail) = hard_break(face, &current, size_px, max_width)?;
249                lines.push(head);
250                current = tail;
251            }
252        } else {
253            lines.push(current.clone());
254            current = word.trim_start().to_string();
255        }
256    }
257    if !current.is_empty() {
258        lines.push(current);
259    }
260    Ok(())
261}
262
263/// Split a string into "word + leading whitespace" tokens. Each
264/// returned token starts with zero-or-more whitespace characters
265/// followed by zero-or-more non-whitespace characters.
266fn split_keeping_whitespace(s: &str) -> Vec<String> {
267    let mut out: Vec<String> = Vec::new();
268    let mut buf = String::new();
269    let mut in_word = false;
270    for ch in s.chars() {
271        if ch.is_whitespace() {
272            if in_word {
273                out.push(std::mem::take(&mut buf));
274                in_word = false;
275            }
276            buf.push(ch);
277        } else {
278            in_word = true;
279            buf.push(ch);
280        }
281    }
282    if !buf.is_empty() {
283        out.push(buf);
284    }
285    out
286}
287
288/// Cut `text` so the prefix shapes within `max_width`. Returns
289/// `(head, tail)` — `head` is everything that fit, `tail` is the rest.
290fn hard_break(
291    face: &Face,
292    text: &str,
293    size_px: f32,
294    max_width: f32,
295) -> Result<(String, String), Error> {
296    let chars: Vec<char> = text.chars().collect();
297    let mut last_good = 0usize;
298    for n in 1..=chars.len() {
299        let candidate: String = chars[..n].iter().collect();
300        let glyphs = Shaper::shape(face, &candidate, size_px)?;
301        if run_width(&glyphs) > max_width {
302            break;
303        }
304        last_good = n;
305    }
306    if last_good == 0 {
307        // Even the first character overflows; emit it anyway so we
308        // don't loop forever.
309        last_good = 1.min(chars.len());
310    }
311    let head: String = chars[..last_good].iter().collect();
312    let tail: String = chars[last_good..].iter().collect();
313    Ok((head, tail))
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn split_keeping_whitespace_basic() {
322        let v = split_keeping_whitespace("hello world foo");
323        assert_eq!(v, vec!["hello", " world", " foo"]);
324    }
325
326    #[test]
327    fn split_keeping_whitespace_leading_trailing() {
328        let v = split_keeping_whitespace("  hi");
329        assert_eq!(v, vec!["  hi"]);
330    }
331
332    #[test]
333    fn empty_text_is_empty_lines() {
334        // No face required for empty text.
335        // Build a dummy by reusing the Face::from_ttf_bytes path on a
336        // real fixture.
337        // (No fixture in unit tests — run with the integration test
338        // harness for the real measure-and-wrap path.)
339    }
340
341    #[test]
342    fn visual_ltr_is_identity() {
343        let line = reorder_line_visual("abc", None);
344        assert_eq!(line.base_level, 0);
345        assert_eq!(line.to_visual_string(), "abc");
346        assert_eq!(line.logical_to_visual, vec![0, 1, 2]);
347        assert_eq!(line.visual_to_logical, vec![0, 1, 2]);
348        assert_eq!(line.len(), 3);
349        assert!(!line.is_empty());
350    }
351
352    #[test]
353    fn visual_empty_line() {
354        let line = reorder_line_visual("", None);
355        assert!(line.is_empty());
356        assert_eq!(line.len(), 0);
357        assert_eq!(line.to_visual_string(), "");
358        assert!(line.logical_to_visual.is_empty());
359        assert!(line.visual_to_logical.is_empty());
360    }
361
362    #[test]
363    fn visual_pure_rtl_reverses() {
364        // Three Hebrew letters: a pure-RTL line resolves to base level
365        // 1 and the visual order is the logical order reversed.
366        let line = reorder_line_visual("\u{05D0}\u{05D1}\u{05D2}", None);
367        assert_eq!(line.base_level, 1);
368        // Visual = logical reversed.
369        assert_eq!(line.logical_to_visual, vec![2, 1, 0]);
370        assert_eq!(line.to_visual_string(), "\u{05D2}\u{05D1}\u{05D0}");
371        // Inverse permutation is consistent with the forward one.
372        for (vis_pos, &log_idx) in line.logical_to_visual.iter().enumerate() {
373            assert_eq!(line.visual_to_logical[log_idx], vis_pos);
374        }
375    }
376
377    #[test]
378    fn visual_permutation_is_a_bijection() {
379        // Mixed Latin + Hebrew + digits + space: whatever the
380        // reordering, the permutation must remain a bijection and the
381        // two permutation vectors must invert each other.
382        let line = reorder_line_visual("ab \u{05D0}\u{05D1}12", None);
383        let n = line.len();
384        let mut seen = vec![false; n];
385        for &log_idx in &line.logical_to_visual {
386            assert!(log_idx < n);
387            assert!(!seen[log_idx], "permutation repeats index {log_idx}");
388            seen[log_idx] = true;
389        }
390        assert!(seen.iter().all(|&b| b));
391        for (vis_pos, &log_idx) in line.logical_to_visual.iter().enumerate() {
392            assert_eq!(line.visual_to_logical[log_idx], vis_pos);
393        }
394    }
395
396    #[test]
397    fn visual_base_level_override() {
398        // Forcing base level 1 (RTL) on an all-Latin line flips it to
399        // RTL: the line as a whole is laid out right-to-left even though
400        // its strong characters are L.
401        let ltr = reorder_line_visual("abc", Some(0));
402        assert_eq!(ltr.base_level, 0);
403        assert_eq!(ltr.to_visual_string(), "abc");
404
405        let rtl = reorder_line_visual("abc", Some(1));
406        assert_eq!(rtl.base_level, 1);
407        // The Latin run is one level-2 LTR island inside the level-1
408        // line, so within the run the characters keep their order.
409        assert_eq!(rtl.to_visual_string(), "abc");
410        // But the low-bit clamp accepts any odd value as RTL.
411        let rtl2 = reorder_line_visual("abc", Some(3));
412        assert_eq!(rtl2.base_level, 1);
413    }
414
415    #[test]
416    fn visual_l4_mirrors_rtl_bracket() {
417        // A parenthesis inside a pure-RTL line resolves to an odd level
418        // and L4 swaps it for its mirror glyph in the visual output.
419        // Logical: he-alef '(' he-bet  ->  the '(' is at an odd level,
420        // so the rendered glyph is the mirrored ')'.
421        let line = reorder_line_visual("\u{05D0}(\u{05D1}", None);
422        assert_eq!(line.base_level, 1);
423        let s = line.to_visual_string();
424        // The line is reversed and the bracket is mirrored: visual order
425        // is bet, mirrored-'(' = ')', alef.
426        assert!(
427            s.contains(')'),
428            "expected mirrored ')' in RTL line, got {s:?}"
429        );
430        assert!(!s.contains('('), "original '(' should have been mirrored");
431    }
432
433    #[test]
434    fn visual_ltr_bracket_not_mirrored() {
435        // The same bracket in an LTR line stays unmirrored (even level).
436        let line = reorder_line_visual("a(b)", None);
437        assert_eq!(line.base_level, 0);
438        assert_eq!(line.to_visual_string(), "a(b)");
439    }
440}