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}