Skip to main content

oxideav_scribe/
face.rs

1//! `Face` — owning wrapper around `oxideav_ttf::Font` /
2//! `oxideav_otf::Font` plus per-face identity for the glyph-bitmap
3//! cache.
4//!
5//! `Font<'a>` (in either underlying crate) borrows from the input
6//! bytes which makes it awkward to pass around in a higher-level
7//! renderer. `Face` owns the bytes via a boxed slice and re-parses
8//! on demand through [`Face::with_font`] / [`Face::with_otf_font`].
9//! We deliberately avoid `Pin` / self-referential structs (no
10//! third-party deps allowed); the cost of a one-line re-parse on
11//! each call is ~microseconds and dwarfed by glyph rasterisation.
12//!
13//! TTF and OTF cohabit through a [`FaceKind`] tag. The TTF path
14//! returns quadratic-Bezier outlines (`oxideav_ttf::TtOutline`); the
15//! OTF path returns cubic-Bezier outlines (`oxideav_otf::CubicOutline`).
16//! Higher-level rasterisation code can dispatch via
17//! [`Face::flatten_outline`] which converts whichever native form
18//! the face holds into the unified `FlatOutline` polyline.
19
20use crate::Error;
21use oxideav_core::{
22    FillRule, ImageRef, Node, Paint, Path, PathCommand, PathNode, Point, Rect, Rgba, Transform2D,
23    VideoFrame, VideoPlane,
24};
25use oxideav_ttf::{NamedInstance, VariationAxis};
26
27/// Which underlying parser this face wraps.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum FaceKind {
30    /// TrueType / quadratic-Bezier outlines (`oxideav-ttf`).
31    Ttf,
32    /// OpenType-CFF / cubic-Bezier outlines (`oxideav-otf`).
33    Otf,
34}
35
36/// Monotonic global id generator for `Face` instances. Used as the
37/// primary key when caching rasterised glyph bitmaps so that two
38/// faces that happen to share family names don't collide.
39fn next_face_id() -> u64 {
40    use std::sync::atomic::{AtomicU64, Ordering};
41    static NEXT: AtomicU64 = AtomicU64::new(1);
42    NEXT.fetch_add(1, Ordering::Relaxed)
43}
44
45/// An owning, re-parseable wrapper around either an
46/// `oxideav_ttf::Font` or an `oxideav_otf::Font`. The discriminant
47/// is recorded in [`Face::kind`] so callers can pick the right
48/// outline path.
49#[derive(Debug)]
50pub struct Face {
51    bytes: Box<[u8]>,
52    id: u64,
53    kind: FaceKind,
54    units_per_em: u16,
55    ascent: i16,
56    descent: i16,
57    line_gap: i16,
58    family: Option<String>,
59    italic_angle: f32,
60    weight_class: u16,
61    /// `Some(i)` when this face was constructed from a TTC subfont via
62    /// `from_ttc_bytes`. `with_font` re-parses through the TTC entry
63    /// point so the right subfont is selected each time. `None` for
64    /// plain sfnt-flavour faces (the common case).
65    subfont_index: Option<u32>,
66    /// Current variation coordinates (one entry per `fvar` axis, in
67    /// declaration order, in user-space units). Empty for static fonts
68    /// or until [`Face::set_variation_coords`] is called. When non-empty
69    /// and the font is variable, [`Face::with_font`] re-applies the
70    /// vector to every freshly-parsed `Font<'_>` so glyph outline
71    /// lookups consume the gvar-blended outline.
72    var_coords: Vec<f32>,
73}
74
75impl Face {
76    /// Parse a TTF from owned bytes.
77    pub fn from_ttf_bytes(bytes: Vec<u8>) -> Result<Self, Error> {
78        let bytes: Box<[u8]> = bytes.into_boxed_slice();
79        // Snapshot the metadata while we have the borrow.
80        let (units_per_em, ascent, descent, line_gap, family, italic_angle, weight_class) = {
81            let font = oxideav_ttf::Font::from_bytes(&bytes).map_err(Error::from)?;
82            (
83                font.units_per_em(),
84                font.ascent(),
85                font.descent(),
86                font.line_gap(),
87                font.family_name().map(|s| s.to_string()),
88                font.italic_angle(),
89                font.weight_class(),
90            )
91        };
92        Ok(Self {
93            bytes,
94            id: next_face_id(),
95            kind: FaceKind::Ttf,
96            units_per_em,
97            ascent,
98            descent,
99            line_gap,
100            family,
101            italic_angle,
102            weight_class,
103            subfont_index: None,
104            var_coords: Vec::new(),
105        })
106    }
107
108    /// Parse the `index`-th subfont out of an owned TrueType Collection
109    /// (`.ttc` / `'ttcf'`) byte buffer. Convenience wrapper around
110    /// `oxideav_ttf::Font::from_collection_bytes`. Index is recorded on
111    /// the face so [`Face::with_font`] can re-parse the right subfont.
112    pub fn from_ttc_bytes(bytes: Vec<u8>, index: u32) -> Result<Self, Error> {
113        let bytes: Box<[u8]> = bytes.into_boxed_slice();
114        let (units_per_em, ascent, descent, line_gap, family, italic_angle, weight_class) = {
115            let font =
116                oxideav_ttf::Font::from_collection_bytes(&bytes, index).map_err(Error::from)?;
117            (
118                font.units_per_em(),
119                font.ascent(),
120                font.descent(),
121                font.line_gap(),
122                font.family_name().map(|s| s.to_string()),
123                font.italic_angle(),
124                font.weight_class(),
125            )
126        };
127        Ok(Self {
128            bytes,
129            id: next_face_id(),
130            kind: FaceKind::Ttf,
131            units_per_em,
132            ascent,
133            descent,
134            line_gap,
135            family,
136            italic_angle,
137            weight_class,
138            subfont_index: Some(index),
139            var_coords: Vec::new(),
140        })
141    }
142
143    /// Parse an OTF (OpenType-CFF) font from owned bytes. Returns
144    /// a `Face` whose [`Face::kind`] is [`FaceKind::Otf`] and whose
145    /// outlines come back as cubic Beziers via the cubic flattener
146    /// in [`crate::outline`].
147    pub fn from_otf_bytes(bytes: Vec<u8>) -> Result<Self, Error> {
148        let bytes: Box<[u8]> = bytes.into_boxed_slice();
149        let (units_per_em, ascent, descent, line_gap, family) = {
150            let font = oxideav_otf::Font::from_bytes(&bytes).map_err(Error::from)?;
151            (
152                font.units_per_em(),
153                font.ascent(),
154                font.descent(),
155                font.line_gap(),
156                font.family_name().map(|s| s.to_string()),
157            )
158        };
159        Ok(Self {
160            bytes,
161            id: next_face_id(),
162            kind: FaceKind::Otf,
163            units_per_em,
164            ascent,
165            descent,
166            line_gap,
167            family,
168            // OTF (CFF) carries italicAngle in the Top DICT. We
169            // don't surface it through the Font public API in
170            // round 1 — italic synthesis can fall back to the OS/2
171            // (slant) heuristic via weight_class. Defaulting to 0
172            // matches "upright".
173            italic_angle: 0.0,
174            // Round 1 of oxideav-otf doesn't expose OS/2 either;
175            // 400 (Regular) is the safe default that avoids
176            // synthetic-bold heuristics firing.
177            weight_class: 400,
178            subfont_index: None,
179            // OTF / CFF2 variation support is out of scope for the
180            // initial round; any caller setting variation coords on an
181            // OTF face is a no-op (with_otf_font does not reapply).
182            var_coords: Vec::new(),
183        })
184    }
185
186    /// Underlying parser flavour for this face.
187    pub fn kind(&self) -> FaceKind {
188        self.kind
189    }
190
191    /// Stable per-process id for this face. Used as the first component
192    /// of the glyph-bitmap cache key.
193    pub fn id(&self) -> u64 {
194        self.id
195    }
196
197    /// Stable, content-derived identity for this face that is **stable
198    /// across loads of the same font bytes** (in contrast to [`Face::id`]
199    /// which is a per-process counter). Used as the producer-side
200    /// component of the [`oxideav_core::Group::cache_key`] emitted by
201    /// [`crate::Shaper::shape_to_paths`] so the downstream rasterizer's
202    /// bitmap cache reuses the same memoised glyph across renderer
203    /// instances and across program restarts.
204    ///
205    /// Implementation: a `DefaultHasher` digest of the font's leading
206    /// bytes (up to 256) plus the byte length, plus the TTC subfont
207    /// index when applicable. Two faces parsed from the same bytes get
208    /// the same `stable_id`; two distinct fonts almost certainly do
209    /// not.
210    pub fn stable_id(&self) -> u64 {
211        use std::hash::{DefaultHasher, Hash, Hasher};
212        let mut h = DefaultHasher::new();
213        // Tag the discriminant + subfont so a TTC's subfont 0 and
214        // subfont 1 (which share the outer byte buffer) end up with
215        // different ids without us having to hash the entire TTC twice.
216        (self.kind as u8).hash(&mut h);
217        self.subfont_index.hash(&mut h);
218        // Include the total byte length so two fonts that share a
219        // common header prefix (rare but possible across stripped
220        // variants of the same family) still distinguish.
221        (self.bytes.len() as u64).hash(&mut h);
222        // Hash the leading bytes — the sfnt header + the table
223        // directory both live here and are highly font-specific.
224        let prefix = &self.bytes[..self.bytes.len().min(256)];
225        prefix.hash(&mut h);
226        h.finish()
227    }
228
229    /// Family name from the font's `name` table. May be `None` for
230    /// stripped or non-standard fonts.
231    pub fn family_name(&self) -> Option<&str> {
232        self.family.as_deref()
233    }
234
235    /// Units per em (`head.unitsPerEm`). Practically always 1024 or
236    /// 2048; never zero in valid fonts.
237    pub fn units_per_em(&self) -> u16 {
238        self.units_per_em
239    }
240
241    /// Typographic ascent in raster pixels at `size_px`.
242    pub fn ascent_px(&self, size_px: f32) -> f32 {
243        self.ascent as f32 * size_px / self.units_per_em as f32
244    }
245
246    /// Typographic descent in raster pixels (negative for fonts with
247    /// strokes below the baseline).
248    pub fn descent_px(&self, size_px: f32) -> f32 {
249        self.descent as f32 * size_px / self.units_per_em as f32
250    }
251
252    /// Recommended line height: `ascent - descent + line_gap`, in
253    /// raster pixels.
254    pub fn line_height_px(&self, size_px: f32) -> f32 {
255        let units = self.ascent as i32 - self.descent as i32 + self.line_gap as i32;
256        units as f32 * size_px / self.units_per_em as f32
257    }
258
259    /// `post.italicAngle` in degrees (negative for forward slanted
260    /// faces, 0 for upright). Used by [`crate::style`] to decide
261    /// whether to synthesise italic for an upright font or honour the
262    /// font's own slant.
263    pub fn italic_angle(&self) -> f32 {
264        self.italic_angle
265    }
266
267    /// `OS/2.usWeightClass` (100..=1000). 400 if the font has no
268    /// `OS/2` table.
269    pub fn weight_class(&self) -> u16 {
270        self.weight_class
271    }
272
273    /// Run a closure with a freshly-parsed `oxideav_ttf::Font<'_>`
274    /// view of the owned bytes. We re-parse on each call instead of
275    /// storing a self-referential `Font<'static>` (which would
276    /// require unsafe or a third-party crate like `ouroboros`, both
277    /// of which we avoid). Re-parsing is read-only header walking —
278    /// well under a millisecond on any modern font.
279    ///
280    /// Returns `Error::WrongFaceKind` if this face was constructed
281    /// from OTF bytes; use [`Face::with_otf_font`] in that case.
282    pub fn with_font<R>(&self, f: impl FnOnce(&oxideav_ttf::Font<'_>) -> R) -> Result<R, Error> {
283        if self.kind != FaceKind::Ttf {
284            return Err(Error::WrongFaceKind {
285                expected: FaceKind::Ttf,
286                actual: self.kind,
287            });
288        }
289        let mut font = match self.subfont_index {
290            Some(i) => {
291                oxideav_ttf::Font::from_collection_bytes(&self.bytes, i).map_err(Error::from)?
292            }
293            None => oxideav_ttf::Font::from_bytes(&self.bytes).map_err(Error::from)?,
294        };
295        // Re-apply any caller-set variation coords after the parse so
296        // glyph_outline (and downstream advances / kerning that consume
297        // it) reflect the gvar-blended outline. No-op for static fonts
298        // (the underlying setter short-circuits when there is no fvar).
299        if !self.var_coords.is_empty() {
300            font.set_variation_coords(&self.var_coords);
301        }
302        Ok(f(&font))
303    }
304
305    /// True if this face is the `i`-th subfont of a TrueType Collection
306    /// (the `bytes` buffer holds the WHOLE TTC; the subfont is selected
307    /// at parse-time). Returns `None` for plain sfnt-flavour faces.
308    pub fn subfont_index(&self) -> Option<u32> {
309        self.subfont_index
310    }
311
312    // ---- variable fonts (fvar / avar / gvar) -----------------------------
313
314    /// `true` if the underlying font ships an `fvar` table — i.e. it
315    /// exposes one or more variation axes. `false` for OTF faces and
316    /// for static TTF faces.
317    pub fn is_variable(&self) -> bool {
318        if self.kind != FaceKind::Ttf {
319            return false;
320        }
321        self.with_font(|f| f.is_variable()).unwrap_or(false)
322    }
323
324    /// All variation axes the font publishes, cloned out of the
325    /// underlying `fvar`. Empty for static / OTF faces. Each
326    /// [`VariationAxis`] carries `min` / `default` / `max` plus the
327    /// `tag` (`b"wght"` / `b"wdth"` / `b"opsz"` / …) and the `name_id`
328    /// for the human-readable axis label.
329    pub fn variation_axes(&self) -> Vec<VariationAxis> {
330        if self.kind != FaceKind::Ttf {
331            return Vec::new();
332        }
333        self.with_font(|f| f.variation_axes().to_vec())
334            .unwrap_or_default()
335    }
336
337    /// All named instances (pre-defined axis vectors like "Light",
338    /// "Regular", "Bold") the font publishes, in declaration order.
339    /// Empty for static / OTF faces. Each [`NamedInstance`] carries
340    /// `subfamily_name_id` (a `name`-table id for the subfamily label),
341    /// `coords` (one `f32` per axis matching [`Self::variation_axes`]),
342    /// and an optional `post_script_name_id`.
343    ///
344    /// Callers that want to pick an instance by axis vector (e.g. "the
345    /// instance whose `wght=900`") iterate this slice and inspect
346    /// `coords`. Resolving the human-readable subfamily label requires
347    /// reading the `name` table directly via [`Self::with_font`] —
348    /// scribe deliberately doesn't surface a bespoke
349    /// `name_id → string` accessor.
350    pub fn named_instances(&self) -> Vec<NamedInstance> {
351        if self.kind != FaceKind::Ttf {
352            return Vec::new();
353        }
354        self.with_font(|f| f.named_instances().to_vec())
355            .unwrap_or_default()
356    }
357
358    /// Current user-space variation coordinates (one entry per axis,
359    /// in `fvar` declaration order). Empty before any
360    /// [`Self::set_variation_coords`] call AND for static / OTF faces.
361    pub fn variation_coords(&self) -> &[f32] {
362        &self.var_coords
363    }
364
365    /// Set the user-space variation coordinates that scribe will
366    /// re-apply on every [`Self::with_font`] re-parse, so subsequent
367    /// glyph outline lookups consume the gvar-blended outline at those
368    /// coords. Each entry is in **user-space** units (e.g. `wght` is
369    /// 100..900 on Inter).
370    ///
371    /// The vector is silently length-normalised against the axis count
372    /// — shorter vectors leave the trailing axes at their previous
373    /// value (or each axis's default for a fresh face), longer vectors
374    /// are truncated. Out-of-range values are clamped to each axis's
375    /// `[min, max]` *via the underlying parser*, so the value visible
376    /// on a subsequent [`Self::variation_coords`] call may differ from
377    /// what was passed in. No-op for static / OTF faces.
378    ///
379    /// Pre-condition: this method works for [`FaceKind::Ttf`] faces
380    /// only. Calling it on an OTF face returns `Err(WrongFaceKind)`
381    /// (variable CFF2 / OTF is out of scope for the initial round).
382    pub fn set_variation_coords(&mut self, coords: &[f32]) -> Result<(), Error> {
383        if self.kind != FaceKind::Ttf {
384            return Err(Error::WrongFaceKind {
385                expected: FaceKind::Ttf,
386                actual: self.kind,
387            });
388        }
389        // Round-trip through a freshly-parsed parser so the per-axis
390        // length cap + `[min, max]` clamp the underlying setter applies
391        // is preserved on round-trip. The freshly-parsed `Font` is
392        // discarded after the round-trip — we only persist the clamped
393        // f32 vector so subsequent `with_font` re-applies it.
394        let mut font = match self.subfont_index {
395            Some(i) => {
396                oxideav_ttf::Font::from_collection_bytes(&self.bytes, i).map_err(Error::from)?
397            }
398            None => oxideav_ttf::Font::from_bytes(&self.bytes).map_err(Error::from)?,
399        };
400        // Seed with whatever the parser exposes as the current vector
401        // (axis defaults on a fresh face; the previously-set vector if
402        // we re-set with a partial `coords` argument). Then merge the
403        // caller-supplied entries on top, then call the parser to
404        // clamp + length-cap.
405        let mut working = font.variation_coords().to_vec();
406        if !self.var_coords.is_empty() {
407            for (i, &v) in self.var_coords.iter().enumerate() {
408                if i >= working.len() {
409                    break;
410                }
411                working[i] = v;
412            }
413        }
414        for (i, &v) in coords.iter().enumerate() {
415            if i >= working.len() {
416                break;
417            }
418            working[i] = v;
419        }
420        font.set_variation_coords(&working);
421        self.var_coords = font.variation_coords().to_vec();
422        Ok(())
423    }
424
425    /// Reset the variation coordinates to the empty vector — i.e.
426    /// subsequent `with_font` re-parses fall back to each axis's
427    /// `default` value (the static-font baseline). No-op when no
428    /// coords were ever set.
429    pub fn clear_variation_coords(&mut self) {
430        self.var_coords.clear();
431    }
432
433    /// Run a closure with a freshly-parsed `oxideav_otf::Font<'_>`
434    /// view of the owned bytes. Mirrors [`Face::with_font`] for the
435    /// CFF / cubic-Bezier path.
436    ///
437    /// Returns `Error::WrongFaceKind` if this face was constructed
438    /// from TTF bytes.
439    pub fn with_otf_font<R>(
440        &self,
441        f: impl FnOnce(&oxideav_otf::Font<'_>) -> R,
442    ) -> Result<R, Error> {
443        if self.kind != FaceKind::Otf {
444            return Err(Error::WrongFaceKind {
445                expected: FaceKind::Otf,
446                actual: self.kind,
447            });
448        }
449        let font = oxideav_otf::Font::from_bytes(&self.bytes).map_err(Error::from)?;
450        Ok(f(&font))
451    }
452
453    /// Returns the raw glyph outline as vector commands in the font's
454    /// **native Y-up font-unit coordinate space** (no Y-flip, no scaling
455    /// applied — the canonical "1 em = `units_per_em` units" frame).
456    ///
457    /// - TT outlines map to `MoveTo` + `LineTo` + `QuadCurveTo` + `Close`.
458    ///   Two consecutive off-curve points expand to an implicit on-curve
459    ///   midpoint (the standard TrueType reconstruction rule).
460    /// - CFF outlines map to `MoveTo` + `LineTo` + `CubicCurveTo` +
461    ///   `Close`, mirroring the Type 2 charstring decode directly.
462    /// - Bitmap-only glyphs (CBDT/sbix where the face has no outline
463    ///   table) return `None`. Use [`Face::glyph_node`] for a vector
464    ///   wrapper that handles the bitmap-vs-outline dispatch.
465    /// - COLRv1 layered glyphs (round-6 work) return the *base* outline
466    ///   here; the layered group is exposed via [`Face::glyph_node`] in
467    ///   round 6.
468    ///
469    /// The Y-axis convention deliberately stays Y-up so the returned
470    /// `Path` is "what the font says". Callers that want Y-down
471    /// (oxideav-core's render convention) should compose with
472    /// `Transform2D::scale(scale, -scale)` and an appropriate
473    /// translation, or use [`Face::glyph_node`] which bakes that flip
474    /// into a render-ready `Node`.
475    pub fn glyph_path(&self, glyph_id: u16) -> Option<Path> {
476        match self.kind {
477            FaceKind::Ttf => {
478                // CBDT-only glyphs (e.g. emoji) parse as empty outlines.
479                let outline = self.with_font(|f| f.glyph_outline(glyph_id)).ok()?.ok()?;
480                if outline.contours.is_empty() {
481                    return None;
482                }
483                Some(tt_outline_to_path(&outline))
484            }
485            FaceKind::Otf => {
486                let outline = self
487                    .with_otf_font(|f| f.glyph_outline(glyph_id))
488                    .ok()?
489                    .ok()?;
490                if outline.contours.is_empty() {
491                    return None;
492                }
493                Some(cff_outline_to_path(&outline))
494            }
495        }
496    }
497
498    /// Returns a self-contained `Node` for `glyph_id` ready to be
499    /// positioned at the pen origin — its local origin (0, 0) is the
500    /// glyph's pen origin, X grows rightward, Y grows downward (matching
501    /// oxideav-core / SVG / PDF raster conventions). The Y-flip + size
502    /// scale are baked into the path so callers don't have to reason
503    /// about font units.
504    ///
505    /// Dispatch:
506    /// - **Outline glyph** → `Node::Path(PathNode { path, fill: Some(black), .. })`.
507    ///   Replace `fill` if the caller wants colour.
508    /// - **Bitmap glyph** (CBDT/sbix from round 5) → `Node::Image(ImageRef { ... })`
509    ///   carrying the rasterised RGBA bitmap as a `VideoFrame`. Bounds
510    ///   are sized to the bitmap's CBDT-declared placement at the
511    ///   strike's native ppem (caller scales via the outer
512    ///   `Transform2D` when blitting at a non-strike size).
513    /// - **COLRv1 layered glyph** (round 6 — not yet implemented) — for
514    ///   now, falls through to the outline path. Round 6 will return a
515    ///   `Group` of `PathNode`s here, one per COLR layer.
516    ///
517    /// Returns `None` for empty / non-rendering glyphs (e.g. SPACE).
518    pub fn glyph_node(&self, glyph_id: u16, size_px: f32) -> Option<Node> {
519        if size_px <= 0.0 || !size_px.is_finite() {
520            return None;
521        }
522
523        // Bitmap dispatch first: a face that ships CBDT for this glyph
524        // (typical for emoji codepoints) wins over any empty-outline
525        // fallback. CBDT-only fonts have no outline at all so this
526        // branch is the only path that produces a renderable glyph.
527        //
528        // Round 6 (#356): use `raster_color_glyph_at` so the bitmap is
529        // bilinearly resampled to `size_px` at decode-time. The
530        // resulting `Node::Image` carries a bitmap whose dimensions
531        // match `bounds.width / .height` 1:1 — downstream rasterizers
532        // can blit it without a separate scale step. (Pre-resampling at
533        // the decode boundary is also where the cache-key story lives:
534        // a 32 px bitmap derived from the 109 px strike is the same
535        // every time and can be memoised by callers via the wrapping
536        // `Group::cache_key` from `Shaper::shape_to_paths`.)
537        if matches!(self.kind, FaceKind::Ttf) && self.has_color_bitmaps() {
538            if let Ok(Some(cgb)) = self.raster_color_glyph_at(glyph_id, size_px) {
539                if !cgb.bitmap.is_empty() {
540                    let w = cgb.bitmap.width;
541                    let h = cgb.bitmap.height;
542                    // Pack the RGBA8 into a VideoFrame with one plane.
543                    let stride = (w as usize) * 4;
544                    let frame = VideoFrame {
545                        pts: None,
546                        planes: vec![VideoPlane {
547                            stride,
548                            data: cgb.bitmap.data.clone(),
549                        }],
550                    };
551                    // bearing_x / bearing_y / advance from
552                    // raster_color_glyph_at are already in raster pixels
553                    // at `size_px` (pre-scaled by `size_px / strike_ppem`).
554                    let bx = cgb.bearing_x as f32;
555                    let by = -(cgb.bearing_y as f32);
556                    let bw = w as f32;
557                    let bh = h as f32;
558                    return Some(Node::Image(ImageRef {
559                        frame: Box::new(frame),
560                        bounds: Rect {
561                            x: bx,
562                            y: by,
563                            width: bw,
564                            height: bh,
565                        },
566                        transform: Transform2D::identity(),
567                    }));
568                }
569            }
570        }
571
572        // Outline path: take the Y-up native Path and bake in
573        // `scale * (1, -1)` so the resulting Path lives directly in
574        // Y-down raster pixels at `size_px`. (We could ship a wrapping
575        // `Group { transform: scale(scale, -scale), .. }` instead;
576        // baking the transform keeps the Node leaf-shaped and lets
577        // shape_to_paths emit a pure translation per glyph.)
578        let raw = self.glyph_path(glyph_id)?;
579        let upem = self.units_per_em.max(1) as f32;
580        let scale = size_px / upem;
581        let path = scale_and_flip_path(&raw, scale);
582        Some(Node::Path(PathNode {
583            path,
584            fill: Some(Paint::Solid(Rgba::opaque(0, 0, 0))),
585            stroke: None,
586            fill_rule: FillRule::NonZero,
587        }))
588    }
589}
590
591// -- Outline → vector::Path converters -----------------------------------
592
593/// Apply `(x, y) -> (x*scale, -y*scale)` to every coordinate in `src`.
594/// Used by [`Face::glyph_node`] to bake the Y-flip + size-scale into the
595/// returned outline so the `Path` is in raster pixels (Y-down) ready to
596/// blit, without an enclosing `Group::transform`.
597fn scale_and_flip_path(src: &Path, scale: f32) -> Path {
598    let mut out = Path {
599        commands: Vec::with_capacity(src.commands.len()),
600    };
601    let map = |p: Point| Point::new(p.x * scale, -p.y * scale);
602    for cmd in &src.commands {
603        // Glyph outlines never emit ArcTo (TT/CFF have no arc primitive
604        // — TT's quadratics + CFF's cubics are it), so the match arms
605        // below cover every variant we'll ever see. The wildcard is a
606        // forward-compat safety net for the `#[non_exhaustive]` enum.
607        let new = match *cmd {
608            PathCommand::MoveTo(p) => PathCommand::MoveTo(map(p)),
609            PathCommand::LineTo(p) => PathCommand::LineTo(map(p)),
610            PathCommand::QuadCurveTo { control, end } => PathCommand::QuadCurveTo {
611                control: map(control),
612                end: map(end),
613            },
614            PathCommand::CubicCurveTo { c1, c2, end } => PathCommand::CubicCurveTo {
615                c1: map(c1),
616                c2: map(c2),
617                end: map(end),
618            },
619            PathCommand::Close => PathCommand::Close,
620            other => other,
621        };
622        out.commands.push(new);
623    }
624    out
625}
626
627/// Convert a TrueType outline (quadratic Beziers in font-unit Y-up
628/// coordinates) to a [`Path`] of MoveTo / LineTo / QuadCurveTo / Close.
629///
630/// Implements the standard TrueType reconstruction:
631/// - Pick the first on-curve point of each contour as the starting
632///   point (or the midpoint of `pts[0]..pts[1]` if every point is
633///   off-curve — the rare "phantom on-curve" Apple-TT case).
634/// - On-curve after on-curve → `LineTo`.
635/// - On-curve after off-curve → `QuadCurveTo { control: prev_off, end: on }`.
636/// - Off-curve after off-curve → emit an implicit on-curve at the
637///   midpoint via `QuadCurveTo`, then keep walking with the new
638///   off-curve as the next control point.
639/// - Trailing off-curve at end-of-contour curves back to the start
640///   point.
641/// - Each contour terminates with `PathCommand::Close`.
642fn tt_outline_to_path(outline: &oxideav_ttf::TtOutline) -> Path {
643    let mut out = Path::new();
644    for contour in &outline.contours {
645        let pts = &contour.points;
646        if pts.is_empty() {
647            continue;
648        }
649        let n = pts.len();
650        // Find the first on-curve point; if none, synthesise a start at
651        // the midpoint of pts[0]..pts[1] (Apple-TT phantom on-curve).
652        let start_idx = pts.iter().position(|p| p.on_curve);
653        let (start_xy, ordered): (Point, Vec<(Point, bool)>) = if let Some(s) = start_idx {
654            let mut ord: Vec<(Point, bool)> = Vec::with_capacity(n);
655            for i in 0..n {
656                let p = pts[(s + i) % n];
657                ord.push((Point::new(p.x as f32, p.y as f32), p.on_curve));
658            }
659            (ord[0].0, ord)
660        } else {
661            let p0 = pts[0];
662            let p1 = pts[1 % n];
663            let mid = Point::new(
664                (p0.x as f32 + p1.x as f32) * 0.5,
665                (p0.y as f32 + p1.y as f32) * 0.5,
666            );
667            let mut ord: Vec<(Point, bool)> = Vec::with_capacity(n + 1);
668            ord.push((mid, true));
669            for p in pts.iter().take(n) {
670                ord.push((Point::new(p.x as f32, p.y as f32), p.on_curve));
671            }
672            (mid, ord)
673        };
674
675        out.commands.push(PathCommand::MoveTo(start_xy));
676        let mut prev_off: Option<Point> = None;
677        for &(xy, on) in ordered.iter().skip(1) {
678            if on {
679                if let Some(c) = prev_off.take() {
680                    out.commands.push(PathCommand::QuadCurveTo {
681                        control: c,
682                        end: xy,
683                    });
684                } else {
685                    out.commands.push(PathCommand::LineTo(xy));
686                }
687            } else if let Some(c) = prev_off {
688                // Two off-curve points in a row → emit a quadratic to
689                // their midpoint, then keep the new off-curve as the
690                // next control.
691                let mid = Point::new((c.x + xy.x) * 0.5, (c.y + xy.y) * 0.5);
692                out.commands.push(PathCommand::QuadCurveTo {
693                    control: c,
694                    end: mid,
695                });
696                prev_off = Some(xy);
697            } else {
698                prev_off = Some(xy);
699            }
700        }
701        // Trailing off-curve curves back to the start.
702        if let Some(c) = prev_off.take() {
703            out.commands.push(PathCommand::QuadCurveTo {
704                control: c,
705                end: start_xy,
706            });
707        }
708        out.commands.push(PathCommand::Close);
709    }
710    out
711}
712
713/// Convert a CFF cubic outline (Type 2 charstring decode) to a [`Path`]
714/// of MoveTo / LineTo / CubicCurveTo / Close. The CFF segment IR is
715/// already explicit, so this is a 1:1 mapping — no on/off-curve dance.
716fn cff_outline_to_path(outline: &oxideav_otf::CubicOutline) -> Path {
717    let mut out = Path::new();
718    for contour in &outline.contours {
719        for seg in &contour.segments {
720            match *seg {
721                oxideav_otf::CubicSegment::MoveTo(p) => {
722                    out.commands.push(PathCommand::MoveTo(Point::new(p.x, p.y)));
723                }
724                oxideav_otf::CubicSegment::LineTo(p) => {
725                    out.commands.push(PathCommand::LineTo(Point::new(p.x, p.y)));
726                }
727                oxideav_otf::CubicSegment::CurveTo { c1, c2, end } => {
728                    out.commands.push(PathCommand::CubicCurveTo {
729                        c1: Point::new(c1.x, c1.y),
730                        c2: Point::new(c2.x, c2.y),
731                        end: Point::new(end.x, end.y),
732                    });
733                }
734                oxideav_otf::CubicSegment::ClosePath => {
735                    out.commands.push(PathCommand::Close);
736                }
737            }
738        }
739    }
740    out
741}