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