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::face::Face;
11use crate::shaper::{PositionedGlyph, Shaper};
12use crate::Error;
13
14/// Width of a shaped run in raster pixels: cumulative advance + the
15/// trailing glyph's offset (which is normally 0; included for correctness
16/// when round-2 mark-to-base attachment lands).
17pub fn run_width(glyphs: &[PositionedGlyph]) -> f32 {
18    let mut w = 0.0;
19    for g in glyphs {
20        w += g.x_advance + g.x_offset;
21    }
22    w
23}
24
25/// Break `text` into lines that fit within `max_width` after shaping.
26/// Whitespace runs are the preferred break points; a single word that
27/// is wider than `max_width` is broken character-by-character so the
28/// caller never receives an over-wide line.
29///
30/// Returns the line strings (not their shaped output) — the caller
31/// usually feeds each line back into [`Shaper::shape`] for the final
32/// composition step.
33pub fn wrap_lines(
34    face: &Face,
35    text: &str,
36    size_px: f32,
37    max_width: f32,
38) -> Result<Vec<String>, Error> {
39    if text.is_empty() {
40        return Ok(Vec::new());
41    }
42    if max_width <= 0.0 {
43        // Caller didn't constrain width — return one line per actual
44        // newline (collapsing them is wrong; preserving them is the
45        // least-surprise default).
46        return Ok(text.split('\n').map(|s| s.to_string()).collect());
47    }
48
49    let mut lines: Vec<String> = Vec::new();
50    for paragraph in text.split('\n') {
51        wrap_paragraph(face, paragraph, size_px, max_width, &mut lines)?;
52    }
53    Ok(lines)
54}
55
56fn wrap_paragraph(
57    face: &Face,
58    text: &str,
59    size_px: f32,
60    max_width: f32,
61    lines: &mut Vec<String>,
62) -> Result<(), Error> {
63    if text.is_empty() {
64        lines.push(String::new());
65        return Ok(());
66    }
67
68    // Tokenise on whitespace, keeping the spaces attached to the
69    // following word so the trailing-space behaviour is consistent.
70    let words: Vec<String> = split_keeping_whitespace(text);
71    if words.is_empty() {
72        lines.push(text.to_string());
73        return Ok(());
74    }
75
76    let mut current = String::new();
77    for word in words {
78        let candidate = if current.is_empty() {
79            word.trim_start().to_string()
80        } else {
81            format!("{current}{word}")
82        };
83        let glyphs = Shaper::shape(face, &candidate, size_px)?;
84        if run_width(&glyphs) <= max_width || current.is_empty() {
85            current = candidate;
86            // If even the first word doesn't fit, hard-break it.
87            let cur_glyphs = Shaper::shape(face, &current, size_px)?;
88            if run_width(&cur_glyphs) > max_width {
89                let (head, tail) = hard_break(face, &current, size_px, max_width)?;
90                lines.push(head);
91                current = tail;
92            }
93        } else {
94            lines.push(current.clone());
95            current = word.trim_start().to_string();
96        }
97    }
98    if !current.is_empty() {
99        lines.push(current);
100    }
101    Ok(())
102}
103
104/// Split a string into "word + leading whitespace" tokens. Each
105/// returned token starts with zero-or-more whitespace characters
106/// followed by zero-or-more non-whitespace characters.
107fn split_keeping_whitespace(s: &str) -> Vec<String> {
108    let mut out: Vec<String> = Vec::new();
109    let mut buf = String::new();
110    let mut in_word = false;
111    for ch in s.chars() {
112        if ch.is_whitespace() {
113            if in_word {
114                out.push(std::mem::take(&mut buf));
115                in_word = false;
116            }
117            buf.push(ch);
118        } else {
119            in_word = true;
120            buf.push(ch);
121        }
122    }
123    if !buf.is_empty() {
124        out.push(buf);
125    }
126    out
127}
128
129/// Cut `text` so the prefix shapes within `max_width`. Returns
130/// `(head, tail)` — `head` is everything that fit, `tail` is the rest.
131fn hard_break(
132    face: &Face,
133    text: &str,
134    size_px: f32,
135    max_width: f32,
136) -> Result<(String, String), Error> {
137    let chars: Vec<char> = text.chars().collect();
138    let mut last_good = 0usize;
139    for n in 1..=chars.len() {
140        let candidate: String = chars[..n].iter().collect();
141        let glyphs = Shaper::shape(face, &candidate, size_px)?;
142        if run_width(&glyphs) > max_width {
143            break;
144        }
145        last_good = n;
146    }
147    if last_good == 0 {
148        // Even the first character overflows; emit it anyway so we
149        // don't loop forever.
150        last_good = 1.min(chars.len());
151    }
152    let head: String = chars[..last_good].iter().collect();
153    let tail: String = chars[last_good..].iter().collect();
154    Ok((head, tail))
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn split_keeping_whitespace_basic() {
163        let v = split_keeping_whitespace("hello world foo");
164        assert_eq!(v, vec!["hello", " world", " foo"]);
165    }
166
167    #[test]
168    fn split_keeping_whitespace_leading_trailing() {
169        let v = split_keeping_whitespace("  hi");
170        assert_eq!(v, vec!["  hi"]);
171    }
172
173    #[test]
174    fn empty_text_is_empty_lines() {
175        // No face required for empty text.
176        // Build a dummy by reusing the Face::from_ttf_bytes path on a
177        // real fixture.
178        // (No fixture in unit tests — run with the integration test
179        // harness for the real measure-and-wrap path.)
180    }
181}