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_style: crate::types::UnderlineStyle::None,
173        overline: false,
174        strikeout: false,
175        is_link: false,
176        foreground_color: None,
177        underline_color: None,
178        background_color: None,
179        anchor_href: None,
180        tooltip: None,
181        vertical_alignment: crate::types::VerticalAlignment::Normal,
182        image_name: None,
183        image_height: 0.0,
184    })
185}
186
187/// Shape a text string, reusing a UnicodeBuffer to avoid allocations.
188pub fn shape_text_with_buffer(
189    registry: &FontRegistry,
190    resolved: &ResolvedFont,
191    text: &str,
192    text_offset: usize,
193    buffer: UnicodeBuffer,
194) -> Option<(ShapedRun, UnicodeBuffer)> {
195    let entry = registry.get(resolved.font_face_id)?;
196    let face = Face::from_slice(&entry.data, entry.face_index)?;
197
198    let units_per_em = face.units_per_em() as f32;
199    if units_per_em == 0.0 {
200        return None;
201    }
202    let scale = resolved.size_px / units_per_em;
203
204    let mut buffer = buffer;
205    buffer.push_str(text);
206
207    let glyph_buffer = rustybuzz::shape(&face, &[], buffer);
208
209    let infos = glyph_buffer.glyph_infos();
210    let positions = glyph_buffer.glyph_positions();
211
212    let mut glyphs = Vec::with_capacity(infos.len());
213    let mut total_advance = 0.0f32;
214
215    for (info, pos) in infos.iter().zip(positions.iter()) {
216        let x_advance = pos.x_advance as f32 * scale;
217        let y_advance = pos.y_advance as f32 * scale;
218        let x_offset = pos.x_offset as f32 * scale;
219        let y_offset = pos.y_offset as f32 * scale;
220
221        glyphs.push(ShapedGlyph {
222            glyph_id: info.glyph_id as u16,
223            cluster: info.cluster,
224            x_advance,
225            y_advance,
226            x_offset,
227            y_offset,
228            font_face_id: resolved.font_face_id,
229        });
230
231        total_advance += x_advance;
232    }
233
234    let run = ShapedRun {
235        font_face_id: resolved.font_face_id,
236        size_px: resolved.size_px,
237        glyphs,
238        advance_width: total_advance,
239        text_range: text_offset..text_offset + text.len(),
240        underline_style: crate::types::UnderlineStyle::None,
241        overline: false,
242        strikeout: false,
243        is_link: false,
244        foreground_color: None,
245        underline_color: None,
246        background_color: None,
247        anchor_href: None,
248        tooltip: None,
249        vertical_alignment: crate::types::VerticalAlignment::Normal,
250        image_name: None,
251        image_height: 0.0,
252    };
253
254    // Reclaim the buffer for reuse
255    let recycled = glyph_buffer.clear();
256    Some((run, recycled))
257}
258
259/// Get font metrics (ascent, descent, leading) scaled to pixels.
260pub fn font_metrics_px(registry: &FontRegistry, resolved: &ResolvedFont) -> Option<FontMetricsPx> {
261    let entry = registry.get(resolved.font_face_id)?;
262    let font_ref = swash::FontRef::from_index(&entry.data, entry.face_index as usize)?;
263    let metrics = font_ref.metrics(&[]).scale(resolved.size_px);
264
265    Some(FontMetricsPx {
266        ascent: metrics.ascent,
267        descent: metrics.descent,
268        leading: metrics.leading,
269        underline_offset: metrics.underline_offset,
270        strikeout_offset: metrics.strikeout_offset,
271        stroke_size: metrics.stroke_size,
272    })
273}
274
275/// A bidi run: a contiguous range of text with the same direction.
276pub struct BidiRun {
277    pub byte_range: std::ops::Range<usize>,
278    pub direction: TextDirection,
279    /// Visual order index (for reordering after line breaking).
280    pub visual_order: usize,
281}
282
283/// Analyze text for bidirectional content and return directional runs.
284/// If the text is purely LTR, returns a single run.
285pub fn bidi_runs(text: &str) -> Vec<BidiRun> {
286    use unicode_bidi::BidiInfo;
287
288    if text.is_empty() {
289        return vec![BidiRun {
290            byte_range: 0..0,
291            direction: TextDirection::LeftToRight,
292            visual_order: 0,
293        }];
294    }
295
296    let bidi_info = BidiInfo::new(text, None);
297
298    let mut runs = Vec::new();
299
300    for para in &bidi_info.paragraphs {
301        let para_text = &text[para.range.clone()];
302        let para_offset = para.range.start;
303
304        // Get levels for this paragraph
305        let levels = &bidi_info.levels[para.range.clone()];
306
307        // Split into runs of same level
308        if levels.is_empty() {
309            continue;
310        }
311
312        let mut run_start = 0usize;
313        let mut current_level = levels[0];
314
315        for (i, &level) in levels.iter().enumerate() {
316            if level != current_level {
317                // Emit previous run
318                let dir = if current_level.is_rtl() {
319                    TextDirection::RightToLeft
320                } else {
321                    TextDirection::LeftToRight
322                };
323                // Snap to char boundaries
324                let start = snap_to_char_boundary(para_text, run_start);
325                let end = snap_to_char_boundary(para_text, i);
326                if start < end {
327                    runs.push(BidiRun {
328                        byte_range: (para_offset + start)..(para_offset + end),
329                        direction: dir,
330                        visual_order: runs.len(),
331                    });
332                }
333                run_start = i;
334                current_level = level;
335            }
336        }
337
338        // Emit final run
339        let dir = if current_level.is_rtl() {
340            TextDirection::RightToLeft
341        } else {
342            TextDirection::LeftToRight
343        };
344        let start = snap_to_char_boundary(para_text, run_start);
345        let end = para_text.len();
346        if start < end {
347            runs.push(BidiRun {
348                byte_range: (para_offset + start)..(para_offset + end),
349                direction: dir,
350                visual_order: runs.len(),
351            });
352        }
353    }
354
355    if runs.is_empty() {
356        runs.push(BidiRun {
357            byte_range: 0..text.len(),
358            direction: TextDirection::LeftToRight,
359            visual_order: 0,
360        });
361    }
362
363    runs
364}
365
366fn snap_to_char_boundary(text: &str, byte_pos: usize) -> usize {
367    if byte_pos >= text.len() {
368        return text.len();
369    }
370    // Walk forward to the next char boundary
371    let mut pos = byte_pos;
372    while pos < text.len() && !text.is_char_boundary(pos) {
373        pos += 1;
374    }
375    pos
376}
377
378pub struct FontMetricsPx {
379    pub ascent: f32,
380    pub descent: f32,
381    pub leading: f32,
382    pub underline_offset: f32,
383    pub strikeout_offset: f32,
384    pub stroke_size: f32,
385}