Skip to main content

text_typeset/shaping/
shaper.rs

1use rustybuzz::{Direction, Face, UnicodeBuffer};
2
3use crate::font::registry::FontRegistry;
4use crate::font::resolve::ResolvedFont;
5use crate::shaping::run::{ShapedGlyph, ShapedRun};
6
7/// Text direction for shaping.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum TextDirection {
10    /// Auto-detect from text content (default).
11    #[default]
12    Auto,
13    LeftToRight,
14    RightToLeft,
15}
16
17/// Shape a text string with the given resolved font.
18///
19/// Returns a ShapedRun with glyph IDs and pixel-space positions.
20/// The `text_offset` is the byte offset of this text within the block
21/// (used for cluster mapping back to document positions).
22/// Shape a text string with automatic glyph fallback.
23///
24/// After shaping with the primary font, any .notdef glyphs (glyph_id==0)
25/// are detected and re-shaped with fallback fonts. If no fallback font
26/// covers a character, it remains as .notdef (renders as blank space
27/// with correct advance).
28pub fn shape_text(
29    registry: &FontRegistry,
30    resolved: &ResolvedFont,
31    text: &str,
32    text_offset: usize,
33) -> Option<ShapedRun> {
34    let mut run = shape_text_directed(registry, resolved, text, text_offset, TextDirection::Auto)?;
35
36    // Check for .notdef glyphs and attempt fallback
37    if run.glyphs.iter().any(|g| g.glyph_id == 0) && !text.is_empty() {
38        apply_glyph_fallback(registry, resolved, text, text_offset, &mut run);
39    }
40
41    Some(run)
42}
43
44/// Re-shape .notdef glyphs using fallback fonts.
45///
46/// For each .notdef glyph, finds the source character via the cluster value,
47/// queries all registered fonts for coverage, and if one covers it,
48/// shapes that single character with the fallback font and replaces
49/// the .notdef glyph with the result.
50fn apply_glyph_fallback(
51    registry: &FontRegistry,
52    primary: &ResolvedFont,
53    text: &str,
54    text_offset: usize,
55    run: &mut ShapedRun,
56) {
57    use crate::font::resolve::find_fallback_font;
58
59    for glyph in &mut run.glyphs {
60        if glyph.glyph_id != 0 {
61            continue;
62        }
63
64        // Find the character that produced this .notdef
65        let byte_offset = glyph.cluster as usize;
66        let ch = match text.get(byte_offset..).and_then(|s| s.chars().next()) {
67            Some(c) => c,
68            None => continue,
69        };
70
71        // Find a fallback font that has this character
72        let fallback_id = match find_fallback_font(registry, ch, primary.font_face_id) {
73            Some(id) => id,
74            None => continue, // no fallback available -leave as .notdef
75        };
76
77        let fallback_entry = match registry.get(fallback_id) {
78            Some(e) => e,
79            None => continue,
80        };
81
82        // Shape just this character with the fallback font
83        let fallback_resolved = ResolvedFont {
84            font_face_id: fallback_id,
85            size_px: primary.size_px,
86            face_index: fallback_entry.face_index,
87            swash_cache_key: fallback_entry.swash_cache_key,
88        };
89
90        let char_str = &text[byte_offset..byte_offset + ch.len_utf8()];
91        if let Some(fallback_run) = shape_text_directed(
92            registry,
93            &fallback_resolved,
94            char_str,
95            text_offset + byte_offset,
96            TextDirection::Auto,
97        ) {
98            // Replace the .notdef glyph with the fallback glyph(s)
99            if let Some(fb_glyph) = fallback_run.glyphs.first() {
100                glyph.glyph_id = fb_glyph.glyph_id;
101                glyph.x_advance = fb_glyph.x_advance;
102                glyph.y_advance = fb_glyph.y_advance;
103                glyph.x_offset = fb_glyph.x_offset;
104                glyph.y_offset = fb_glyph.y_offset;
105                glyph.font_face_id = fallback_id;
106            }
107        }
108    }
109
110    // Recompute total advance
111    run.advance_width = run.glyphs.iter().map(|g| g.x_advance).sum();
112}
113
114/// Shape text with an explicit direction.
115pub fn shape_text_directed(
116    registry: &FontRegistry,
117    resolved: &ResolvedFont,
118    text: &str,
119    text_offset: usize,
120    direction: TextDirection,
121) -> Option<ShapedRun> {
122    let entry = registry.get(resolved.font_face_id)?;
123    let face = Face::from_slice(&entry.data, entry.face_index)?;
124
125    let units_per_em = face.units_per_em() as f32;
126    if units_per_em == 0.0 {
127        return None;
128    }
129    let scale = resolved.size_px / units_per_em;
130
131    let mut buffer = UnicodeBuffer::new();
132    buffer.push_str(text);
133    match direction {
134        TextDirection::LeftToRight => buffer.set_direction(Direction::LeftToRight),
135        TextDirection::RightToLeft => buffer.set_direction(Direction::RightToLeft),
136        TextDirection::Auto => {} // let rustybuzz guess
137    }
138
139    let glyph_buffer = rustybuzz::shape(&face, &[], buffer);
140
141    let infos = glyph_buffer.glyph_infos();
142    let positions = glyph_buffer.glyph_positions();
143
144    let mut glyphs = Vec::with_capacity(infos.len());
145    let mut total_advance = 0.0f32;
146
147    for (info, pos) in infos.iter().zip(positions.iter()) {
148        let x_advance = pos.x_advance as f32 * scale;
149        let y_advance = pos.y_advance as f32 * scale;
150        let x_offset = pos.x_offset as f32 * scale;
151        let y_offset = pos.y_offset as f32 * scale;
152
153        glyphs.push(ShapedGlyph {
154            glyph_id: info.glyph_id as u16,
155            cluster: info.cluster,
156            x_advance,
157            y_advance,
158            x_offset,
159            y_offset,
160            font_face_id: resolved.font_face_id,
161        });
162
163        total_advance += x_advance;
164    }
165
166    Some(ShapedRun {
167        font_face_id: resolved.font_face_id,
168        size_px: resolved.size_px,
169        glyphs,
170        advance_width: total_advance,
171        text_range: text_offset..text_offset + text.len(),
172        underline: false,
173        overline: false,
174        strikeout: false,
175        is_link: false,
176    })
177}
178
179/// Shape a text string, reusing a UnicodeBuffer to avoid allocations.
180pub fn shape_text_with_buffer(
181    registry: &FontRegistry,
182    resolved: &ResolvedFont,
183    text: &str,
184    text_offset: usize,
185    buffer: UnicodeBuffer,
186) -> Option<(ShapedRun, UnicodeBuffer)> {
187    let entry = registry.get(resolved.font_face_id)?;
188    let face = Face::from_slice(&entry.data, entry.face_index)?;
189
190    let units_per_em = face.units_per_em() as f32;
191    if units_per_em == 0.0 {
192        return None;
193    }
194    let scale = resolved.size_px / units_per_em;
195
196    let mut buffer = buffer;
197    buffer.push_str(text);
198
199    let glyph_buffer = rustybuzz::shape(&face, &[], buffer);
200
201    let infos = glyph_buffer.glyph_infos();
202    let positions = glyph_buffer.glyph_positions();
203
204    let mut glyphs = Vec::with_capacity(infos.len());
205    let mut total_advance = 0.0f32;
206
207    for (info, pos) in infos.iter().zip(positions.iter()) {
208        let x_advance = pos.x_advance as f32 * scale;
209        let y_advance = pos.y_advance as f32 * scale;
210        let x_offset = pos.x_offset as f32 * scale;
211        let y_offset = pos.y_offset as f32 * scale;
212
213        glyphs.push(ShapedGlyph {
214            glyph_id: info.glyph_id as u16,
215            cluster: info.cluster,
216            x_advance,
217            y_advance,
218            x_offset,
219            y_offset,
220            font_face_id: resolved.font_face_id,
221        });
222
223        total_advance += x_advance;
224    }
225
226    let run = ShapedRun {
227        font_face_id: resolved.font_face_id,
228        size_px: resolved.size_px,
229        glyphs,
230        advance_width: total_advance,
231        text_range: text_offset..text_offset + text.len(),
232        underline: false,
233        overline: false,
234        strikeout: false,
235        is_link: false,
236    };
237
238    // Reclaim the buffer for reuse
239    let recycled = glyph_buffer.clear();
240    Some((run, recycled))
241}
242
243/// Get font metrics (ascent, descent, leading) scaled to pixels.
244pub fn font_metrics_px(registry: &FontRegistry, resolved: &ResolvedFont) -> Option<FontMetricsPx> {
245    let entry = registry.get(resolved.font_face_id)?;
246    let font_ref = swash::FontRef::from_index(&entry.data, entry.face_index as usize)?;
247    let metrics = font_ref.metrics(&[]).scale(resolved.size_px);
248
249    Some(FontMetricsPx {
250        ascent: metrics.ascent,
251        descent: metrics.descent,
252        leading: metrics.leading,
253        underline_offset: metrics.underline_offset,
254        strikeout_offset: metrics.strikeout_offset,
255        stroke_size: metrics.stroke_size,
256    })
257}
258
259/// A bidi run: a contiguous range of text with the same direction.
260pub struct BidiRun {
261    pub byte_range: std::ops::Range<usize>,
262    pub direction: TextDirection,
263    /// Visual order index (for reordering after line breaking).
264    pub visual_order: usize,
265}
266
267/// Analyze text for bidirectional content and return directional runs.
268/// If the text is purely LTR, returns a single run.
269pub fn bidi_runs(text: &str) -> Vec<BidiRun> {
270    use unicode_bidi::BidiInfo;
271
272    if text.is_empty() {
273        return vec![BidiRun {
274            byte_range: 0..0,
275            direction: TextDirection::LeftToRight,
276            visual_order: 0,
277        }];
278    }
279
280    let bidi_info = BidiInfo::new(text, None);
281
282    let mut runs = Vec::new();
283
284    for para in &bidi_info.paragraphs {
285        let para_text = &text[para.range.clone()];
286        let para_offset = para.range.start;
287
288        // Get levels for this paragraph
289        let levels = &bidi_info.levels[para.range.clone()];
290
291        // Split into runs of same level
292        if levels.is_empty() {
293            continue;
294        }
295
296        let mut run_start = 0usize;
297        let mut current_level = levels[0];
298
299        for (i, &level) in levels.iter().enumerate() {
300            if level != current_level {
301                // Emit previous run
302                let dir = if current_level.is_rtl() {
303                    TextDirection::RightToLeft
304                } else {
305                    TextDirection::LeftToRight
306                };
307                // Snap to char boundaries
308                let start = snap_to_char_boundary(para_text, run_start);
309                let end = snap_to_char_boundary(para_text, i);
310                if start < end {
311                    runs.push(BidiRun {
312                        byte_range: (para_offset + start)..(para_offset + end),
313                        direction: dir,
314                        visual_order: runs.len(),
315                    });
316                }
317                run_start = i;
318                current_level = level;
319            }
320        }
321
322        // Emit final run
323        let dir = if current_level.is_rtl() {
324            TextDirection::RightToLeft
325        } else {
326            TextDirection::LeftToRight
327        };
328        let start = snap_to_char_boundary(para_text, run_start);
329        let end = para_text.len();
330        if start < end {
331            runs.push(BidiRun {
332                byte_range: (para_offset + start)..(para_offset + end),
333                direction: dir,
334                visual_order: runs.len(),
335            });
336        }
337    }
338
339    if runs.is_empty() {
340        runs.push(BidiRun {
341            byte_range: 0..text.len(),
342            direction: TextDirection::LeftToRight,
343            visual_order: 0,
344        });
345    }
346
347    runs
348}
349
350fn snap_to_char_boundary(text: &str, byte_pos: usize) -> usize {
351    if byte_pos >= text.len() {
352        return text.len();
353    }
354    // Walk forward to the next char boundary
355    let mut pos = byte_pos;
356    while pos < text.len() && !text.is_char_boundary(pos) {
357        pos += 1;
358    }
359    pos
360}
361
362pub struct FontMetricsPx {
363    pub ascent: f32,
364    pub descent: f32,
365    pub leading: f32,
366    pub underline_offset: f32,
367    pub strikeout_offset: f32,
368    pub stroke_size: f32,
369}