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, ¤t, size_px)?;
247 if run_width(&cur_glyphs) > max_width {
248 let (head, tail) = hard_break(face, ¤t, 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}