Skip to main content

text_typeset/shaping/
shaper.rs

1use harfrust::{Direction, Feature, FontRef, ShapeOptions, Tag, UnicodeBuffer};
2
3use crate::font::registry::FontRegistry;
4use crate::font::resolve::ResolvedFont;
5use crate::shaping::run::{ShapedGlyph, ShapedRun};
6use crate::types::FontFeature;
7
8/// Convert public [`FontFeature`] toggles into harfrust [`Feature`]s,
9/// applied across the whole shaped string (global range). Script-mandated
10/// features apply regardless; these are the discretionary toggles.
11pub fn to_harfrust_features(features: &[FontFeature]) -> Vec<Feature> {
12    features
13        .iter()
14        .map(|f| Feature::new(Tag::new(&f.tag), f.value, ..))
15        .collect()
16}
17
18/// Read units-per-em for a font face.
19///
20/// `harfrust::FontRef` is a thin wrapper over read-fonts and exposes
21/// the `head` table only through the `read_fonts::TableProvider` trait,
22/// which harfrust doesn't re-export. Since we already depend on swash
23/// for `font_metrics_px` further down, we reuse swash's `Metrics` to
24/// pull UPEM — one less dependency surface to maintain.
25fn units_per_em(bytes: &[u8], face_index: u32) -> Option<u16> {
26    let font_ref = swash::FontRef::from_index(bytes, face_index as usize)?;
27    let upem = font_ref.metrics(&[]).units_per_em;
28    if upem == 0 { None } else { Some(upem) }
29}
30
31/// Text direction for shaping.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum TextDirection {
34    /// Auto-detect from text content (default).
35    #[default]
36    Auto,
37    LeftToRight,
38    RightToLeft,
39}
40
41/// Shape a text string with the given resolved font.
42///
43/// Returns a ShapedRun with glyph IDs and pixel-space positions.
44/// The `text_offset` is the byte offset of this text within the block
45/// (used for cluster mapping back to document positions).
46/// Shape a text string with automatic glyph fallback.
47///
48/// After shaping with the primary font, any .notdef glyphs (glyph_id==0)
49/// are detected and re-shaped with fallback fonts. If no fallback font
50/// covers a character, it remains as .notdef (renders as blank space
51/// with correct advance).
52pub fn shape_text(
53    registry: &FontRegistry,
54    resolved: &ResolvedFont,
55    text: &str,
56    text_offset: usize,
57) -> Option<ShapedRun> {
58    shape_text_with_fallback(
59        registry,
60        resolved,
61        text,
62        text_offset,
63        TextDirection::Auto,
64        &[],
65    )
66}
67
68/// Shape text with an explicit direction and glyph fallback.
69///
70/// Like `shape_text`, but caller supplies the direction instead of letting
71/// rustybuzz guess. Used by the bidi-aware layout path, which splits text
72/// into directional runs before shaping.
73pub fn shape_text_with_fallback(
74    registry: &FontRegistry,
75    resolved: &ResolvedFont,
76    text: &str,
77    text_offset: usize,
78    direction: TextDirection,
79    features: &[Feature],
80) -> Option<ShapedRun> {
81    let mut run = shape_text_directed(registry, resolved, text, text_offset, direction, features)?;
82
83    // Check for .notdef glyphs and attempt fallback
84    if run.glyphs.iter().any(|g| g.glyph_id == 0) && !text.is_empty() {
85        apply_glyph_fallback(registry, resolved, text, text_offset, features, &mut run);
86    }
87
88    Some(run)
89}
90
91/// Re-shape .notdef glyphs using fallback fonts.
92///
93/// For each .notdef glyph, finds the source character via the cluster value,
94/// queries all registered fonts for coverage, and if one covers it,
95/// shapes that single character with the fallback font and replaces
96/// the .notdef glyph with the result.
97fn apply_glyph_fallback(
98    registry: &FontRegistry,
99    primary: &ResolvedFont,
100    text: &str,
101    text_offset: usize,
102    features: &[Feature],
103    run: &mut ShapedRun,
104) {
105    use crate::font::resolve::find_fallback_font;
106
107    for glyph in &mut run.glyphs {
108        if glyph.glyph_id != 0 {
109            continue;
110        }
111
112        // Find the character that produced this .notdef
113        let byte_offset = glyph.cluster as usize;
114        let ch = match text.get(byte_offset..).and_then(|s| s.chars().next()) {
115            Some(c) => c,
116            None => continue,
117        };
118
119        // Find a fallback font that has this character
120        let fallback_id = match find_fallback_font(registry, ch, primary.font_face_id) {
121            Some(id) => id,
122            None => continue, // no fallback available -leave as .notdef
123        };
124
125        let fallback_entry = match registry.get(fallback_id) {
126            Some(e) => e,
127            None => continue,
128        };
129
130        // Shape just this character with the fallback font
131        let fallback_resolved = ResolvedFont {
132            font_face_id: fallback_id,
133            size_px: primary.size_px,
134            face_index: fallback_entry.face_index,
135            swash_cache_key: fallback_entry.swash_cache_key,
136            scale_factor: primary.scale_factor,
137            weight: primary.weight,
138        };
139
140        let char_str = &text[byte_offset..byte_offset + ch.len_utf8()];
141        if let Some(fallback_run) = shape_text_directed(
142            registry,
143            &fallback_resolved,
144            char_str,
145            text_offset + byte_offset,
146            TextDirection::Auto,
147            features,
148        ) {
149            // Replace the .notdef glyph with the fallback glyph(s)
150            if let Some(fb_glyph) = fallback_run.glyphs.first() {
151                glyph.glyph_id = fb_glyph.glyph_id;
152                glyph.x_advance = fb_glyph.x_advance;
153                glyph.y_advance = fb_glyph.y_advance;
154                glyph.x_offset = fb_glyph.x_offset;
155                glyph.y_offset = fb_glyph.y_offset;
156                glyph.font_face_id = fallback_id;
157            }
158        }
159    }
160
161    // Recompute total advance
162    run.advance_width = run.glyphs.iter().map(|g| g.x_advance).sum();
163}
164
165/// Shape text with an explicit direction.
166pub fn shape_text_directed(
167    registry: &FontRegistry,
168    resolved: &ResolvedFont,
169    text: &str,
170    text_offset: usize,
171    direction: TextDirection,
172    features: &[Feature],
173) -> Option<ShapedRun> {
174    let entry = registry.get(resolved.font_face_id)?;
175    let font = FontRef::from_index(entry.bytes(), entry.face_index).ok()?;
176
177    let upem = units_per_em(entry.bytes(), entry.face_index).unwrap_or(0) as f32;
178    if upem == 0.0 {
179        return None;
180    }
181    // Shape at physical ppem, then divide results by scale_factor so
182    // downstream layout stays in logical pixels. See ResolvedFont.
183    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
184    let physical_size = resolved.size_px * sf;
185    let physical_scale = physical_size / upem;
186    let inv_sf = 1.0 / sf;
187
188    let mut buffer = UnicodeBuffer::new();
189    buffer.push_str(text);
190    match direction {
191        TextDirection::LeftToRight => buffer.set_direction(Direction::LeftToRight),
192        TextDirection::RightToLeft => buffer.set_direction(Direction::RightToLeft),
193        // harfrust panics if direction is left Invalid; explicitly
194        // guess script + language + direction from the buffer content.
195        TextDirection::Auto => buffer.guess_segment_properties(),
196    }
197
198    // Resolve the concrete direction (Auto is now decided by the buffer).
199    // Stored on the run so hit-testing knows RTL glyph order.
200    let resolved_direction = if buffer.direction() == Direction::RightToLeft {
201        TextDirection::RightToLeft
202    } else {
203        TextDirection::LeftToRight
204    };
205
206    // ShaperData preprocesses font tables for shaping. It's built once
207    // per face and cached on the FontEntry, so repeated shape calls
208    // (every relayout/keystroke) reuse the same preprocessed tables.
209    let shaper_data = entry.shaper_data(&font);
210    let shaper = shaper_data.shaper(&font).build();
211    let glyph_buffer = shaper.shape(buffer, ShapeOptions::new().features(features));
212
213    let infos = glyph_buffer.glyph_infos();
214    let positions = glyph_buffer.glyph_positions();
215
216    let mut glyphs = Vec::with_capacity(infos.len());
217    let mut total_advance = 0.0f32;
218
219    for (info, pos) in infos.iter().zip(positions.iter()) {
220        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
221        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
222        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
223        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
224
225        glyphs.push(ShapedGlyph {
226            glyph_id: info.glyph_id as u16,
227            cluster: info.cluster,
228            x_advance,
229            y_advance,
230            x_offset,
231            y_offset,
232            font_face_id: resolved.font_face_id,
233        });
234
235        total_advance += x_advance;
236    }
237
238    Some(ShapedRun {
239        font_face_id: resolved.font_face_id,
240        size_px: resolved.size_px,
241        weight: resolved.weight,
242        glyphs,
243        advance_width: total_advance,
244        text_range: text_offset..text_offset + text.len(),
245        direction: resolved_direction,
246        underline_style: crate::types::UnderlineStyle::None,
247        overline: false,
248        strikeout: false,
249        is_link: false,
250        foreground_color: None,
251        underline_color: None,
252        background_color: None,
253        anchor_href: None,
254        tooltip: None,
255        vertical_alignment: crate::types::VerticalAlignment::Normal,
256        image_name: None,
257        image_height: 0.0,
258    })
259}
260
261/// Shape a text string, reusing a UnicodeBuffer to avoid allocations.
262pub fn shape_text_with_buffer(
263    registry: &FontRegistry,
264    resolved: &ResolvedFont,
265    text: &str,
266    text_offset: usize,
267    buffer: UnicodeBuffer,
268    features: &[Feature],
269) -> Option<(ShapedRun, UnicodeBuffer)> {
270    let entry = registry.get(resolved.font_face_id)?;
271    let font = FontRef::from_index(entry.bytes(), entry.face_index).ok()?;
272
273    let upem = units_per_em(entry.bytes(), entry.face_index).unwrap_or(0) as f32;
274    if upem == 0.0 {
275        return None;
276    }
277    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
278    let physical_size = resolved.size_px * sf;
279    let physical_scale = physical_size / upem;
280    let inv_sf = 1.0 / sf;
281
282    let mut buffer = buffer;
283    buffer.push_str(text);
284    // Recycled buffers come back without segment properties; explicitly
285    // guess them so harfrust doesn't panic on Direction::Invalid.
286    buffer.guess_segment_properties();
287
288    let resolved_direction = if buffer.direction() == Direction::RightToLeft {
289        TextDirection::RightToLeft
290    } else {
291        TextDirection::LeftToRight
292    };
293
294    let shaper_data = entry.shaper_data(&font);
295    let shaper = shaper_data.shaper(&font).build();
296    let glyph_buffer = shaper.shape(buffer, ShapeOptions::new().features(features));
297
298    let infos = glyph_buffer.glyph_infos();
299    let positions = glyph_buffer.glyph_positions();
300
301    let mut glyphs = Vec::with_capacity(infos.len());
302    let mut total_advance = 0.0f32;
303
304    for (info, pos) in infos.iter().zip(positions.iter()) {
305        let x_advance = pos.x_advance as f32 * physical_scale * inv_sf;
306        let y_advance = pos.y_advance as f32 * physical_scale * inv_sf;
307        let x_offset = pos.x_offset as f32 * physical_scale * inv_sf;
308        let y_offset = pos.y_offset as f32 * physical_scale * inv_sf;
309
310        glyphs.push(ShapedGlyph {
311            glyph_id: info.glyph_id as u16,
312            cluster: info.cluster,
313            x_advance,
314            y_advance,
315            x_offset,
316            y_offset,
317            font_face_id: resolved.font_face_id,
318        });
319
320        total_advance += x_advance;
321    }
322
323    let run = ShapedRun {
324        font_face_id: resolved.font_face_id,
325        size_px: resolved.size_px,
326        weight: resolved.weight,
327        glyphs,
328        advance_width: total_advance,
329        text_range: text_offset..text_offset + text.len(),
330        direction: resolved_direction,
331        underline_style: crate::types::UnderlineStyle::None,
332        overline: false,
333        strikeout: false,
334        is_link: false,
335        foreground_color: None,
336        underline_color: None,
337        background_color: None,
338        anchor_href: None,
339        tooltip: None,
340        vertical_alignment: crate::types::VerticalAlignment::Normal,
341        image_name: None,
342        image_height: 0.0,
343    };
344
345    // Reclaim the buffer for reuse
346    let recycled = glyph_buffer.clear();
347    Some((run, recycled))
348}
349
350/// Get font metrics (ascent, descent, leading) scaled to logical pixels.
351///
352/// Scales at `size_px * scale_factor` (physical) and divides by
353/// `scale_factor`, so callers always see logical-pixel metrics.
354pub fn font_metrics_px(registry: &FontRegistry, resolved: &ResolvedFont) -> Option<FontMetricsPx> {
355    let entry = registry.get(resolved.font_face_id)?;
356    let font_ref = swash::FontRef::from_index(entry.bytes(), entry.face_index as usize)?;
357    let sf = resolved.scale_factor.max(f32::MIN_POSITIVE);
358    let physical_size = resolved.size_px * sf;
359    let metrics = font_ref.metrics(&[]).scale(physical_size);
360    let inv_sf = 1.0 / sf;
361
362    Some(FontMetricsPx {
363        ascent: metrics.ascent * inv_sf,
364        descent: metrics.descent * inv_sf,
365        leading: metrics.leading * inv_sf,
366        underline_offset: metrics.underline_offset * inv_sf,
367        strikeout_offset: metrics.strikeout_offset * inv_sf,
368        stroke_size: metrics.stroke_size * inv_sf,
369    })
370}
371
372/// A bidi run: a contiguous range of text with the same direction.
373pub struct BidiRun {
374    pub byte_range: std::ops::Range<usize>,
375    pub direction: TextDirection,
376    /// Visual order index (for reordering after line breaking).
377    pub visual_order: usize,
378}
379
380/// Analyze text for bidirectional content and return directional runs
381/// in **visual order** per UAX #9 (Unicode Bidirectional Algorithm, rule L2).
382///
383/// The returned runs can be shaped independently and concatenated left-to-right
384/// to produce correctly-ordered mixed-script text (e.g. Latin embedded in
385/// Arabic). For pure-LTR text, returns a single LTR run. For pure-RTL text,
386/// returns a single RTL run.
387pub fn bidi_runs(text: &str) -> Vec<BidiRun> {
388    use unicode_bidi::BidiInfo;
389
390    if text.is_empty() {
391        return Vec::new();
392    }
393
394    let bidi_info = BidiInfo::new(text, None);
395    let mut runs = Vec::new();
396
397    for para in &bidi_info.paragraphs {
398        let (levels, level_runs) = bidi_info.visual_runs(para, para.range.clone());
399        for level_run in level_runs {
400            if level_run.is_empty() {
401                continue;
402            }
403            let level = levels[level_run.start];
404            let direction = if level.is_rtl() {
405                TextDirection::RightToLeft
406            } else {
407                TextDirection::LeftToRight
408            };
409            let visual_order = runs.len();
410            runs.push(BidiRun {
411                byte_range: level_run,
412                direction,
413                visual_order,
414            });
415        }
416    }
417
418    if runs.is_empty() {
419        runs.push(BidiRun {
420            byte_range: 0..text.len(),
421            direction: TextDirection::LeftToRight,
422            visual_order: 0,
423        });
424    }
425
426    runs
427}
428
429pub struct FontMetricsPx {
430    pub ascent: f32,
431    pub descent: f32,
432    pub leading: f32,
433    pub underline_offset: f32,
434    pub strikeout_offset: f32,
435    pub stroke_size: f32,
436}