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    shape_text_with_fallback(registry, resolved, text, text_offset, TextDirection::Auto)
35}
36
37/// Shape text with an explicit direction and glyph fallback.
38///
39/// Like `shape_text`, but caller supplies the direction instead of letting
40/// rustybuzz guess. Used by the bidi-aware layout path, which splits text
41/// into directional runs before shaping.
42pub fn shape_text_with_fallback(
43    registry: &FontRegistry,
44    resolved: &ResolvedFont,
45    text: &str,
46    text_offset: usize,
47    direction: TextDirection,
48) -> Option<ShapedRun> {
49    let mut run = shape_text_directed(registry, resolved, text, text_offset, direction)?;
50
51    // Check for .notdef glyphs and attempt fallback
52    if run.glyphs.iter().any(|g| g.glyph_id == 0) && !text.is_empty() {
53        apply_glyph_fallback(registry, resolved, text, text_offset, &mut run);
54    }
55
56    Some(run)
57}
58
59/// Re-shape .notdef glyphs using fallback fonts.
60///
61/// For each .notdef glyph, finds the source character via the cluster value,
62/// queries all registered fonts for coverage, and if one covers it,
63/// shapes that single character with the fallback font and replaces
64/// the .notdef glyph with the result.
65fn apply_glyph_fallback(
66    registry: &FontRegistry,
67    primary: &ResolvedFont,
68    text: &str,
69    text_offset: usize,
70    run: &mut ShapedRun,
71) {
72    use crate::font::resolve::find_fallback_font;
73
74    for glyph in &mut run.glyphs {
75        if glyph.glyph_id != 0 {
76            continue;
77        }
78
79        // Find the character that produced this .notdef
80        let byte_offset = glyph.cluster as usize;
81        let ch = match text.get(byte_offset..).and_then(|s| s.chars().next()) {
82            Some(c) => c,
83            None => continue,
84        };
85
86        // Find a fallback font that has this character
87        let fallback_id = match find_fallback_font(registry, ch, primary.font_face_id) {
88            Some(id) => id,
89            None => continue, // no fallback available -leave as .notdef
90        };
91
92        let fallback_entry = match registry.get(fallback_id) {
93            Some(e) => e,
94            None => continue,
95        };
96
97        // Shape just this character with the fallback font
98        let fallback_resolved = ResolvedFont {
99            font_face_id: fallback_id,
100            size_px: primary.size_px,
101            face_index: fallback_entry.face_index,
102            swash_cache_key: fallback_entry.swash_cache_key,
103            scale_factor: primary.scale_factor,
104            weight: primary.weight,
105        };
106
107        let char_str = &text[byte_offset..byte_offset + ch.len_utf8()];
108        if let Some(fallback_run) = shape_text_directed(
109            registry,
110            &fallback_resolved,
111            char_str,
112            text_offset + byte_offset,
113            TextDirection::Auto,
114        ) {
115            // Replace the .notdef glyph with the fallback glyph(s)
116            if let Some(fb_glyph) = fallback_run.glyphs.first() {
117                glyph.glyph_id = fb_glyph.glyph_id;
118                glyph.x_advance = fb_glyph.x_advance;
119                glyph.y_advance = fb_glyph.y_advance;
120                glyph.x_offset = fb_glyph.x_offset;
121                glyph.y_offset = fb_glyph.y_offset;
122                glyph.font_face_id = fallback_id;
123            }
124        }
125    }
126
127    // Recompute total advance
128    run.advance_width = run.glyphs.iter().map(|g| g.x_advance).sum();
129}
130
131/// Shape text with an explicit direction.
132pub fn shape_text_directed(
133    registry: &FontRegistry,
134    resolved: &ResolvedFont,
135    text: &str,
136    text_offset: usize,
137    direction: TextDirection,
138) -> Option<ShapedRun> {
139    let entry = registry.get(resolved.font_face_id)?;
140    let face = Face::from_slice(&entry.data, entry.face_index)?;
141
142    let units_per_em = face.units_per_em() as f32;
143    if units_per_em == 0.0 {
144        return None;
145    }
146    // Shape at physical ppem, then divide results by scale_factor so
147    // downstream layout stays in logical pixels. See ResolvedFont.
148    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
149    let physical_size = resolved.size_px * sf;
150    let physical_scale = physical_size / units_per_em;
151    let inv_sf = 1.0 / sf;
152
153    let mut buffer = UnicodeBuffer::new();
154    buffer.push_str(text);
155    match direction {
156        TextDirection::LeftToRight => buffer.set_direction(Direction::LeftToRight),
157        TextDirection::RightToLeft => buffer.set_direction(Direction::RightToLeft),
158        TextDirection::Auto => {} // let rustybuzz guess
159    }
160
161    let glyph_buffer = rustybuzz::shape(&face, &[], buffer);
162
163    let infos = glyph_buffer.glyph_infos();
164    let positions = glyph_buffer.glyph_positions();
165
166    let mut glyphs = Vec::with_capacity(infos.len());
167    let mut total_advance = 0.0f32;
168
169    for (info, pos) in infos.iter().zip(positions.iter()) {
170        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
171        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
172        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
173        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
174
175        glyphs.push(ShapedGlyph {
176            glyph_id: info.glyph_id as u16,
177            cluster: info.cluster,
178            x_advance,
179            y_advance,
180            x_offset,
181            y_offset,
182            font_face_id: resolved.font_face_id,
183        });
184
185        total_advance += x_advance;
186    }
187
188    Some(ShapedRun {
189        font_face_id: resolved.font_face_id,
190        size_px: resolved.size_px,
191        weight: resolved.weight,
192        glyphs,
193        advance_width: total_advance,
194        text_range: text_offset..text_offset + text.len(),
195        underline_style: crate::types::UnderlineStyle::None,
196        overline: false,
197        strikeout: false,
198        is_link: false,
199        foreground_color: None,
200        underline_color: None,
201        background_color: None,
202        anchor_href: None,
203        tooltip: None,
204        vertical_alignment: crate::types::VerticalAlignment::Normal,
205        image_name: None,
206        image_height: 0.0,
207    })
208}
209
210/// Shape a text string, reusing a UnicodeBuffer to avoid allocations.
211pub fn shape_text_with_buffer(
212    registry: &FontRegistry,
213    resolved: &ResolvedFont,
214    text: &str,
215    text_offset: usize,
216    buffer: UnicodeBuffer,
217) -> Option<(ShapedRun, UnicodeBuffer)> {
218    let entry = registry.get(resolved.font_face_id)?;
219    let face = Face::from_slice(&entry.data, entry.face_index)?;
220
221    let units_per_em = face.units_per_em() as f32;
222    if units_per_em == 0.0 {
223        return None;
224    }
225    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
226    let physical_size = resolved.size_px * sf;
227    let physical_scale = physical_size / units_per_em;
228    let inv_sf = 1.0 / sf;
229
230    let mut buffer = buffer;
231    buffer.push_str(text);
232
233    let glyph_buffer = rustybuzz::shape(&face, &[], buffer);
234
235    let infos = glyph_buffer.glyph_infos();
236    let positions = glyph_buffer.glyph_positions();
237
238    let mut glyphs = Vec::with_capacity(infos.len());
239    let mut total_advance = 0.0f32;
240
241    for (info, pos) in infos.iter().zip(positions.iter()) {
242        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
243        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
244        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
245        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
246
247        glyphs.push(ShapedGlyph {
248            glyph_id: info.glyph_id as u16,
249            cluster: info.cluster,
250            x_advance,
251            y_advance,
252            x_offset,
253            y_offset,
254            font_face_id: resolved.font_face_id,
255        });
256
257        total_advance += x_advance;
258    }
259
260    let run = ShapedRun {
261        font_face_id: resolved.font_face_id,
262        size_px: resolved.size_px,
263        weight: resolved.weight,
264        glyphs,
265        advance_width: total_advance,
266        text_range: text_offset..text_offset + text.len(),
267        underline_style: crate::types::UnderlineStyle::None,
268        overline: false,
269        strikeout: false,
270        is_link: false,
271        foreground_color: None,
272        underline_color: None,
273        background_color: None,
274        anchor_href: None,
275        tooltip: None,
276        vertical_alignment: crate::types::VerticalAlignment::Normal,
277        image_name: None,
278        image_height: 0.0,
279    };
280
281    // Reclaim the buffer for reuse
282    let recycled = glyph_buffer.clear();
283    Some((run, recycled))
284}
285
286/// Get font metrics (ascent, descent, leading) scaled to logical pixels.
287///
288/// Scales at `size_px * scale_factor` (physical) and divides by
289/// `scale_factor`, so callers always see logical-pixel metrics.
290pub fn font_metrics_px(registry: &FontRegistry, resolved: &ResolvedFont) -> Option<FontMetricsPx> {
291    let entry = registry.get(resolved.font_face_id)?;
292    let font_ref = swash::FontRef::from_index(&entry.data, entry.face_index as usize)?;
293    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
294    let physical_size = resolved.size_px * sf;
295    let metrics = font_ref.metrics(&[]).scale(physical_size);
296    let inv_sf = 1.0 / sf;
297
298    Some(FontMetricsPx {
299        ascent: metrics.ascent * inv_sf,
300        descent: metrics.descent * inv_sf,
301        leading: metrics.leading * inv_sf,
302        underline_offset: metrics.underline_offset * inv_sf,
303        strikeout_offset: metrics.strikeout_offset * inv_sf,
304        stroke_size: metrics.stroke_size * inv_sf,
305    })
306}
307
308/// A bidi run: a contiguous range of text with the same direction.
309pub struct BidiRun {
310    pub byte_range: std::ops::Range<usize>,
311    pub direction: TextDirection,
312    /// Visual order index (for reordering after line breaking).
313    pub visual_order: usize,
314}
315
316/// Analyze text for bidirectional content and return directional runs
317/// in **visual order** per UAX #9 (Unicode Bidirectional Algorithm, rule L2).
318///
319/// The returned runs can be shaped independently and concatenated left-to-right
320/// to produce correctly-ordered mixed-script text (e.g. Latin embedded in
321/// Arabic). For pure-LTR text, returns a single LTR run. For pure-RTL text,
322/// returns a single RTL run.
323pub fn bidi_runs(text: &str) -> Vec<BidiRun> {
324    use unicode_bidi::BidiInfo;
325
326    if text.is_empty() {
327        return Vec::new();
328    }
329
330    let bidi_info = BidiInfo::new(text, None);
331    let mut runs = Vec::new();
332
333    for para in &bidi_info.paragraphs {
334        let (levels, level_runs) = bidi_info.visual_runs(para, para.range.clone());
335        for level_run in level_runs {
336            if level_run.is_empty() {
337                continue;
338            }
339            let level = levels[level_run.start];
340            let direction = if level.is_rtl() {
341                TextDirection::RightToLeft
342            } else {
343                TextDirection::LeftToRight
344            };
345            let visual_order = runs.len();
346            runs.push(BidiRun {
347                byte_range: level_run,
348                direction,
349                visual_order,
350            });
351        }
352    }
353
354    if runs.is_empty() {
355        runs.push(BidiRun {
356            byte_range: 0..text.len(),
357            direction: TextDirection::LeftToRight,
358            visual_order: 0,
359        });
360    }
361
362    runs
363}
364
365pub struct FontMetricsPx {
366    pub ascent: f32,
367    pub descent: f32,
368    pub leading: f32,
369    pub underline_offset: f32,
370    pub strikeout_offset: f32,
371    pub stroke_size: f32,
372}