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        };
105
106        let char_str = &text[byte_offset..byte_offset + ch.len_utf8()];
107        if let Some(fallback_run) = shape_text_directed(
108            registry,
109            &fallback_resolved,
110            char_str,
111            text_offset + byte_offset,
112            TextDirection::Auto,
113        ) {
114            // Replace the .notdef glyph with the fallback glyph(s)
115            if let Some(fb_glyph) = fallback_run.glyphs.first() {
116                glyph.glyph_id = fb_glyph.glyph_id;
117                glyph.x_advance = fb_glyph.x_advance;
118                glyph.y_advance = fb_glyph.y_advance;
119                glyph.x_offset = fb_glyph.x_offset;
120                glyph.y_offset = fb_glyph.y_offset;
121                glyph.font_face_id = fallback_id;
122            }
123        }
124    }
125
126    // Recompute total advance
127    run.advance_width = run.glyphs.iter().map(|g| g.x_advance).sum();
128}
129
130/// Shape text with an explicit direction.
131pub fn shape_text_directed(
132    registry: &FontRegistry,
133    resolved: &ResolvedFont,
134    text: &str,
135    text_offset: usize,
136    direction: TextDirection,
137) -> Option<ShapedRun> {
138    let entry = registry.get(resolved.font_face_id)?;
139    let face = Face::from_slice(&entry.data, entry.face_index)?;
140
141    let units_per_em = face.units_per_em() as f32;
142    if units_per_em == 0.0 {
143        return None;
144    }
145    // Shape at physical ppem, then divide results by scale_factor so
146    // downstream layout stays in logical pixels. See ResolvedFont.
147    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
148    let physical_size = resolved.size_px * sf;
149    let physical_scale = physical_size / units_per_em;
150    let inv_sf = 1.0 / sf;
151
152    let mut buffer = UnicodeBuffer::new();
153    buffer.push_str(text);
154    match direction {
155        TextDirection::LeftToRight => buffer.set_direction(Direction::LeftToRight),
156        TextDirection::RightToLeft => buffer.set_direction(Direction::RightToLeft),
157        TextDirection::Auto => {} // let rustybuzz guess
158    }
159
160    let glyph_buffer = rustybuzz::shape(&face, &[], buffer);
161
162    let infos = glyph_buffer.glyph_infos();
163    let positions = glyph_buffer.glyph_positions();
164
165    let mut glyphs = Vec::with_capacity(infos.len());
166    let mut total_advance = 0.0f32;
167
168    for (info, pos) in infos.iter().zip(positions.iter()) {
169        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
170        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
171        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
172        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
173
174        glyphs.push(ShapedGlyph {
175            glyph_id: info.glyph_id as u16,
176            cluster: info.cluster,
177            x_advance,
178            y_advance,
179            x_offset,
180            y_offset,
181            font_face_id: resolved.font_face_id,
182        });
183
184        total_advance += x_advance;
185    }
186
187    Some(ShapedRun {
188        font_face_id: resolved.font_face_id,
189        size_px: resolved.size_px,
190        glyphs,
191        advance_width: total_advance,
192        text_range: text_offset..text_offset + text.len(),
193        underline_style: crate::types::UnderlineStyle::None,
194        overline: false,
195        strikeout: false,
196        is_link: false,
197        foreground_color: None,
198        underline_color: None,
199        background_color: None,
200        anchor_href: None,
201        tooltip: None,
202        vertical_alignment: crate::types::VerticalAlignment::Normal,
203        image_name: None,
204        image_height: 0.0,
205    })
206}
207
208/// Shape a text string, reusing a UnicodeBuffer to avoid allocations.
209pub fn shape_text_with_buffer(
210    registry: &FontRegistry,
211    resolved: &ResolvedFont,
212    text: &str,
213    text_offset: usize,
214    buffer: UnicodeBuffer,
215) -> Option<(ShapedRun, UnicodeBuffer)> {
216    let entry = registry.get(resolved.font_face_id)?;
217    let face = Face::from_slice(&entry.data, entry.face_index)?;
218
219    let units_per_em = face.units_per_em() as f32;
220    if units_per_em == 0.0 {
221        return None;
222    }
223    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
224    let physical_size = resolved.size_px * sf;
225    let physical_scale = physical_size / units_per_em;
226    let inv_sf = 1.0 / sf;
227
228    let mut buffer = buffer;
229    buffer.push_str(text);
230
231    let glyph_buffer = rustybuzz::shape(&face, &[], buffer);
232
233    let infos = glyph_buffer.glyph_infos();
234    let positions = glyph_buffer.glyph_positions();
235
236    let mut glyphs = Vec::with_capacity(infos.len());
237    let mut total_advance = 0.0f32;
238
239    for (info, pos) in infos.iter().zip(positions.iter()) {
240        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
241        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
242        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
243        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
244
245        glyphs.push(ShapedGlyph {
246            glyph_id: info.glyph_id as u16,
247            cluster: info.cluster,
248            x_advance,
249            y_advance,
250            x_offset,
251            y_offset,
252            font_face_id: resolved.font_face_id,
253        });
254
255        total_advance += x_advance;
256    }
257
258    let run = ShapedRun {
259        font_face_id: resolved.font_face_id,
260        size_px: resolved.size_px,
261        glyphs,
262        advance_width: total_advance,
263        text_range: text_offset..text_offset + text.len(),
264        underline_style: crate::types::UnderlineStyle::None,
265        overline: false,
266        strikeout: false,
267        is_link: false,
268        foreground_color: None,
269        underline_color: None,
270        background_color: None,
271        anchor_href: None,
272        tooltip: None,
273        vertical_alignment: crate::types::VerticalAlignment::Normal,
274        image_name: None,
275        image_height: 0.0,
276    };
277
278    // Reclaim the buffer for reuse
279    let recycled = glyph_buffer.clear();
280    Some((run, recycled))
281}
282
283/// Get font metrics (ascent, descent, leading) scaled to logical pixels.
284///
285/// Scales at `size_px * scale_factor` (physical) and divides by
286/// `scale_factor`, so callers always see logical-pixel metrics.
287pub fn font_metrics_px(registry: &FontRegistry, resolved: &ResolvedFont) -> Option<FontMetricsPx> {
288    let entry = registry.get(resolved.font_face_id)?;
289    let font_ref = swash::FontRef::from_index(&entry.data, entry.face_index as usize)?;
290    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
291    let physical_size = resolved.size_px * sf;
292    let metrics = font_ref.metrics(&[]).scale(physical_size);
293    let inv_sf = 1.0 / sf;
294
295    Some(FontMetricsPx {
296        ascent: metrics.ascent * inv_sf,
297        descent: metrics.descent * inv_sf,
298        leading: metrics.leading * inv_sf,
299        underline_offset: metrics.underline_offset * inv_sf,
300        strikeout_offset: metrics.strikeout_offset * inv_sf,
301        stroke_size: metrics.stroke_size * inv_sf,
302    })
303}
304
305/// A bidi run: a contiguous range of text with the same direction.
306pub struct BidiRun {
307    pub byte_range: std::ops::Range<usize>,
308    pub direction: TextDirection,
309    /// Visual order index (for reordering after line breaking).
310    pub visual_order: usize,
311}
312
313/// Analyze text for bidirectional content and return directional runs
314/// in **visual order** per UAX #9 (Unicode Bidirectional Algorithm, rule L2).
315///
316/// The returned runs can be shaped independently and concatenated left-to-right
317/// to produce correctly-ordered mixed-script text (e.g. Latin embedded in
318/// Arabic). For pure-LTR text, returns a single LTR run. For pure-RTL text,
319/// returns a single RTL run.
320pub fn bidi_runs(text: &str) -> Vec<BidiRun> {
321    use unicode_bidi::BidiInfo;
322
323    if text.is_empty() {
324        return Vec::new();
325    }
326
327    let bidi_info = BidiInfo::new(text, None);
328    let mut runs = Vec::new();
329
330    for para in &bidi_info.paragraphs {
331        let (levels, level_runs) = bidi_info.visual_runs(para, para.range.clone());
332        for level_run in level_runs {
333            if level_run.is_empty() {
334                continue;
335            }
336            let level = levels[level_run.start];
337            let direction = if level.is_rtl() {
338                TextDirection::RightToLeft
339            } else {
340                TextDirection::LeftToRight
341            };
342            let visual_order = runs.len();
343            runs.push(BidiRun {
344                byte_range: level_run,
345                direction,
346                visual_order,
347            });
348        }
349    }
350
351    if runs.is_empty() {
352        runs.push(BidiRun {
353            byte_range: 0..text.len(),
354            direction: TextDirection::LeftToRight,
355            visual_order: 0,
356        });
357    }
358
359    runs
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}