oxideav-scribe 0.1.9

Pure-Rust vector font shaper + layout for the oxideav framework — TrueType / OTF outline access, GSUB ligatures, GPOS kerning, mark attachment, CBDT colour bitmaps. Pixel pipeline lives in oxideav-raster.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Single-line measurement + word-wrap helpers for round-1.
//!
//! No bidi, no mixed-script reordering — just enough machinery to slice
//! a UTF-8 string into "lines that fit `max_width`" by breaking at
//! whitespace boundaries (or, if a single word overflows, mid-word).
//!
//! The shaper is invoked once per candidate line so kerning and
//! ligatures are correctly accounted for in the width budget.

use crate::bidi::{
    apply_mirroring, bidi_class, process_paragraph_classes_with_brackets, reorder_combining_marks,
    reorder_line, reset_trailing_levels, BidiClass,
};
use crate::face::Face;
use crate::shaper::{PositionedGlyph, Shaper};
use crate::Error;

/// Width of a shaped run in raster pixels: cumulative advance + the
/// trailing glyph's offset (which is normally 0; included for correctness
/// when round-2 mark-to-base attachment lands).
pub fn run_width(glyphs: &[PositionedGlyph]) -> f32 {
    let mut w = 0.0;
    for g in glyphs {
        w += g.x_advance + g.x_offset;
    }
    w
}

/// The visual-order result of driving the full UAX #9 §3 + §3.4
/// bidirectional pipeline over one display line.
///
/// A renderer that wants correct bidirectional text walks
/// [`VisualLine::visual`] left-to-right (the natural rendering
/// direction of the output device), feeding each character to the
/// shaper / cmap and laying the glyphs out in increasing x. The
/// per-character permutation [`VisualLine::logical_to_visual`] and its
/// inverse [`VisualLine::visual_to_logical`] let the caller map a
/// visual glyph back to its source character (cursor hit-testing,
/// selection-rectangle building) and vice versa.
///
/// `visual` already has rule **L4** mirroring applied — every
/// character whose resolved level is odd (right-to-left) and that has
/// a `Bidi_Mirroring_Glyph` pair (a bracket, an angle quotation mark,
/// a mathematical relation, …) is the mirrored code point, not the
/// logical one — so the renderer must *not* mirror again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisualLine {
    /// The line's characters in left-to-right visual order, with L4
    /// mirroring applied. `visual.len()` equals the line's character
    /// count.
    pub visual: Vec<char>,
    /// Permutation entry `k` is the logical index of the character
    /// that belongs at visual position `k` (the UAX #9 §3.4 L2
    /// output). `visual[k]` is the L4-mirrored form of the logical
    /// character at index `logical_to_visual[k]`.
    pub logical_to_visual: Vec<usize>,
    /// The inverse permutation: entry `i` is the visual position of
    /// the character whose logical index is `i`. Equivalent to
    /// inverting [`Self::logical_to_visual`]; precomputed for
    /// O(1) logical-to-visual hit-testing.
    pub visual_to_logical: Vec<usize>,
    /// The paragraph embedding level resolved by P2 / P3 (or supplied
    /// by the caller as the HL1 override). `0` for an LTR line, `1`
    /// for an RTL line.
    pub base_level: u8,
}

impl VisualLine {
    /// Collect [`Self::visual`] into a `String` — the line in the
    /// order a left-to-right renderer paints it.
    #[must_use]
    pub fn to_visual_string(&self) -> String {
        self.visual.iter().collect()
    }

    /// Number of characters in the line.
    #[must_use]
    pub fn len(&self) -> usize {
        self.visual.len()
    }

    /// Whether the line is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.visual.is_empty()
    }
}

/// Drive the complete UAX #9 bidirectional pipeline over a single
/// **display line** and return its characters in left-to-right visual
/// order.
///
/// This is the high-level bridge the [`crate::bidi`] module's per-rule entry
/// points compose into: a caller that has already decided where the
/// paragraph breaks into lines (e.g. via [`wrap_lines`]) passes one
/// line here and receives a [`VisualLine`] whose `visual` field is
/// ready to feed glyph-by-glyph into the shaper in rendering order.
///
/// The pipeline run is, per line:
///
/// 1. **§3.2** class assignment + **§3.3 P → X → W → N0 → N1 / N2 →
///    I** via [`process_paragraph_classes_with_brackets`] (the
///    bracket-aware variant, so paired brackets resolve per N0).
/// 2. **§3.4 L1** trailing-whitespace / separator level reset via
///    [`reset_trailing_levels`] over the whole line.
/// 3. **§3.4 L2** the logical-to-visual permutation via
///    [`reorder_line`].
/// 4. **§3.4 L3** combining-mark reordering via
///    [`reorder_combining_marks`], so a base + its marks stay in
///    `base, mark, …` order after the RTL reversal (the contract a
///    renderer that paints marks after the base needs).
/// 5. **§3.4 L4** mirroring via [`apply_mirroring`], applied to the
///    logical characters at their resolved levels, then projected
///    through the L2 / L3 permutation into `visual`.
///
/// `base_level` is the HL1 higher-level-protocol override: `Some(0)`
/// forces an LTR line, `Some(1)` forces RTL, and `None` lets P2 / P3
/// resolve the base from the line's first strong character.
///
/// A line should be a single paragraph's worth of text (no `B`
/// paragraph separator in the middle); callers split on paragraph
/// separators with [`crate::bidi::split_paragraphs`] before line-breaking.
/// An embedded trailing `B` is handled by L1 like any other line.
///
/// Provenance: composed from the §3 / §3.4 per-rule entry points in
/// the [`crate::bidi`] module, each of which cites
/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html`.
///
/// # Examples
///
/// ```
/// use oxideav_scribe::layout::reorder_line_visual;
///
/// // Pure LTR: visual order equals logical order.
/// let line = reorder_line_visual("abc", None);
/// assert_eq!(line.base_level, 0);
/// assert_eq!(line.to_visual_string(), "abc");
/// assert_eq!(line.logical_to_visual, vec![0, 1, 2]);
/// ```
#[must_use]
pub fn reorder_line_visual(text: &str, base_level: Option<u8>) -> VisualLine {
    let chars: Vec<char> = text.chars().collect();
    let classes: Vec<BidiClass> = chars.iter().copied().map(bidi_class).collect();

    let carrier = process_paragraph_classes_with_brackets(&classes, &chars, base_level);

    // §3.4 L1: reset segment / paragraph separators + trailing
    // whitespace runs to the paragraph level, using the *original*
    // classes per the §3.4 normative note. Work on a clone so the
    // resolved levels used for L4 mirroring stay intact.
    let mut line_levels = carrier.levels.clone();
    reset_trailing_levels(&carrier.classes, &mut line_levels, carrier.paragraph_level);

    // §3.4 L2: the logical-to-visual permutation.
    let mut logical_to_visual = reorder_line(&line_levels);

    // §3.4 L3: keep each base + its combining marks in base-first
    // order after the RTL reversal.
    reorder_combining_marks(&carrier.classes, &line_levels, &mut logical_to_visual);

    // §3.4 L4: mirror odd-resolved-level characters in logical order,
    // then project through the permutation. Mirroring keys off the
    // resolved (post-I) levels, not the L1-reset levels, so a trailing
    // mirrored bracket inside reset whitespace still mirrors correctly.
    let mut mirrored = chars.clone();
    apply_mirroring(&mut mirrored, &carrier.levels);

    let n = chars.len();
    let mut visual = Vec::with_capacity(n);
    let mut visual_to_logical = vec![0usize; n];
    for (vis_pos, &log_idx) in logical_to_visual.iter().enumerate() {
        visual.push(mirrored[log_idx]);
        visual_to_logical[log_idx] = vis_pos;
    }

    VisualLine {
        visual,
        logical_to_visual,
        visual_to_logical,
        base_level: carrier.paragraph_level,
    }
}

/// Break `text` into lines that fit within `max_width` after shaping.
/// Whitespace runs are the preferred break points; a single word that
/// is wider than `max_width` is broken character-by-character so the
/// caller never receives an over-wide line.
///
/// Returns the line strings (not their shaped output) — the caller
/// usually feeds each line back into [`Shaper::shape`] for the final
/// composition step.
pub fn wrap_lines(
    face: &Face,
    text: &str,
    size_px: f32,
    max_width: f32,
) -> Result<Vec<String>, Error> {
    if text.is_empty() {
        return Ok(Vec::new());
    }
    if max_width <= 0.0 {
        // Caller didn't constrain width — return one line per actual
        // newline (collapsing them is wrong; preserving them is the
        // least-surprise default).
        return Ok(text.split('\n').map(|s| s.to_string()).collect());
    }

    let mut lines: Vec<String> = Vec::new();
    for paragraph in text.split('\n') {
        wrap_paragraph(face, paragraph, size_px, max_width, &mut lines)?;
    }
    Ok(lines)
}

fn wrap_paragraph(
    face: &Face,
    text: &str,
    size_px: f32,
    max_width: f32,
    lines: &mut Vec<String>,
) -> Result<(), Error> {
    if text.is_empty() {
        lines.push(String::new());
        return Ok(());
    }

    // Tokenise on whitespace, keeping the spaces attached to the
    // following word so the trailing-space behaviour is consistent.
    let words: Vec<String> = split_keeping_whitespace(text);
    if words.is_empty() {
        lines.push(text.to_string());
        return Ok(());
    }

    let mut current = String::new();
    for word in words {
        let candidate = if current.is_empty() {
            word.trim_start().to_string()
        } else {
            format!("{current}{word}")
        };
        let glyphs = Shaper::shape(face, &candidate, size_px)?;
        if run_width(&glyphs) <= max_width || current.is_empty() {
            current = candidate;
            // If even the first word doesn't fit, hard-break it.
            let cur_glyphs = Shaper::shape(face, &current, size_px)?;
            if run_width(&cur_glyphs) > max_width {
                let (head, tail) = hard_break(face, &current, size_px, max_width)?;
                lines.push(head);
                current = tail;
            }
        } else {
            lines.push(current.clone());
            current = word.trim_start().to_string();
        }
    }
    if !current.is_empty() {
        lines.push(current);
    }
    Ok(())
}

/// Split a string into "word + leading whitespace" tokens. Each
/// returned token starts with zero-or-more whitespace characters
/// followed by zero-or-more non-whitespace characters.
fn split_keeping_whitespace(s: &str) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let mut buf = String::new();
    let mut in_word = false;
    for ch in s.chars() {
        if ch.is_whitespace() {
            if in_word {
                out.push(std::mem::take(&mut buf));
                in_word = false;
            }
            buf.push(ch);
        } else {
            in_word = true;
            buf.push(ch);
        }
    }
    if !buf.is_empty() {
        out.push(buf);
    }
    out
}

/// Cut `text` so the prefix shapes within `max_width`. Returns
/// `(head, tail)` — `head` is everything that fit, `tail` is the rest.
fn hard_break(
    face: &Face,
    text: &str,
    size_px: f32,
    max_width: f32,
) -> Result<(String, String), Error> {
    let chars: Vec<char> = text.chars().collect();
    let mut last_good = 0usize;
    for n in 1..=chars.len() {
        let candidate: String = chars[..n].iter().collect();
        let glyphs = Shaper::shape(face, &candidate, size_px)?;
        if run_width(&glyphs) > max_width {
            break;
        }
        last_good = n;
    }
    if last_good == 0 {
        // Even the first character overflows; emit it anyway so we
        // don't loop forever.
        last_good = 1.min(chars.len());
    }
    let head: String = chars[..last_good].iter().collect();
    let tail: String = chars[last_good..].iter().collect();
    Ok((head, tail))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn split_keeping_whitespace_basic() {
        let v = split_keeping_whitespace("hello world foo");
        assert_eq!(v, vec!["hello", " world", " foo"]);
    }

    #[test]
    fn split_keeping_whitespace_leading_trailing() {
        let v = split_keeping_whitespace("  hi");
        assert_eq!(v, vec!["  hi"]);
    }

    #[test]
    fn empty_text_is_empty_lines() {
        // No face required for empty text.
        // Build a dummy by reusing the Face::from_ttf_bytes path on a
        // real fixture.
        // (No fixture in unit tests — run with the integration test
        // harness for the real measure-and-wrap path.)
    }

    #[test]
    fn visual_ltr_is_identity() {
        let line = reorder_line_visual("abc", None);
        assert_eq!(line.base_level, 0);
        assert_eq!(line.to_visual_string(), "abc");
        assert_eq!(line.logical_to_visual, vec![0, 1, 2]);
        assert_eq!(line.visual_to_logical, vec![0, 1, 2]);
        assert_eq!(line.len(), 3);
        assert!(!line.is_empty());
    }

    #[test]
    fn visual_empty_line() {
        let line = reorder_line_visual("", None);
        assert!(line.is_empty());
        assert_eq!(line.len(), 0);
        assert_eq!(line.to_visual_string(), "");
        assert!(line.logical_to_visual.is_empty());
        assert!(line.visual_to_logical.is_empty());
    }

    #[test]
    fn visual_pure_rtl_reverses() {
        // Three Hebrew letters: a pure-RTL line resolves to base level
        // 1 and the visual order is the logical order reversed.
        let line = reorder_line_visual("\u{05D0}\u{05D1}\u{05D2}", None);
        assert_eq!(line.base_level, 1);
        // Visual = logical reversed.
        assert_eq!(line.logical_to_visual, vec![2, 1, 0]);
        assert_eq!(line.to_visual_string(), "\u{05D2}\u{05D1}\u{05D0}");
        // Inverse permutation is consistent with the forward one.
        for (vis_pos, &log_idx) in line.logical_to_visual.iter().enumerate() {
            assert_eq!(line.visual_to_logical[log_idx], vis_pos);
        }
    }

    #[test]
    fn visual_permutation_is_a_bijection() {
        // Mixed Latin + Hebrew + digits + space: whatever the
        // reordering, the permutation must remain a bijection and the
        // two permutation vectors must invert each other.
        let line = reorder_line_visual("ab \u{05D0}\u{05D1}12", None);
        let n = line.len();
        let mut seen = vec![false; n];
        for &log_idx in &line.logical_to_visual {
            assert!(log_idx < n);
            assert!(!seen[log_idx], "permutation repeats index {log_idx}");
            seen[log_idx] = true;
        }
        assert!(seen.iter().all(|&b| b));
        for (vis_pos, &log_idx) in line.logical_to_visual.iter().enumerate() {
            assert_eq!(line.visual_to_logical[log_idx], vis_pos);
        }
    }

    #[test]
    fn visual_base_level_override() {
        // Forcing base level 1 (RTL) on an all-Latin line flips it to
        // RTL: the line as a whole is laid out right-to-left even though
        // its strong characters are L.
        let ltr = reorder_line_visual("abc", Some(0));
        assert_eq!(ltr.base_level, 0);
        assert_eq!(ltr.to_visual_string(), "abc");

        let rtl = reorder_line_visual("abc", Some(1));
        assert_eq!(rtl.base_level, 1);
        // The Latin run is one level-2 LTR island inside the level-1
        // line, so within the run the characters keep their order.
        assert_eq!(rtl.to_visual_string(), "abc");
        // But the low-bit clamp accepts any odd value as RTL.
        let rtl2 = reorder_line_visual("abc", Some(3));
        assert_eq!(rtl2.base_level, 1);
    }

    #[test]
    fn visual_l4_mirrors_rtl_bracket() {
        // A parenthesis inside a pure-RTL line resolves to an odd level
        // and L4 swaps it for its mirror glyph in the visual output.
        // Logical: he-alef '(' he-bet  ->  the '(' is at an odd level,
        // so the rendered glyph is the mirrored ')'.
        let line = reorder_line_visual("\u{05D0}(\u{05D1}", None);
        assert_eq!(line.base_level, 1);
        let s = line.to_visual_string();
        // The line is reversed and the bracket is mirrored: visual order
        // is bet, mirrored-'(' = ')', alef.
        assert!(
            s.contains(')'),
            "expected mirrored ')' in RTL line, got {s:?}"
        );
        assert!(!s.contains('('), "original '(' should have been mirrored");
    }

    #[test]
    fn visual_ltr_bracket_not_mirrored() {
        // The same bracket in an LTR line stays unmirrored (even level).
        let line = reorder_line_visual("a(b)", None);
        assert_eq!(line.base_level, 0);
        assert_eq!(line.to_visual_string(), "a(b)");
    }
}