Skip to main content

oxideav_scribe/
lib.rs

1//! Pure-Rust font shaper + layout for the
2//! [oxideav](https://github.com/OxideAV) framework.
3//!
4//! Scribe is a **vector-only shaper**: parse TTF / OTF tables → emit
5//! positioned vector glyphs as [`oxideav_core::Node`]s. All pixel
6//! work — outline flattening, scanline anti-aliasing, alpha
7//! compositing — happens downstream in
8//! [`oxideav-raster`](https://github.com/OxideAV/oxideav-raster).
9//!
10//! Scope:
11//! - **Shaper** — `cmap` + GSUB type 4 (ligatures) + GPOS type 2
12//!   (pair kerning) + mark-to-base / mark-to-mark, enough for Latin /
13//!   Cyrillic / Greek / basic CJK / Vietnamese / polytonic Greek.
14//! - **Arabic contextual joining (round 7)** — `shaping::arabic`
15//!   computes the joining form per character using the Unicode joining
16//!   classes + an adjacency state machine; `FaceChain::shape` then
17//!   translates Arabic letters into their Arabic Presentation Forms-B
18//!   equivalents (U+FE70..U+FEFF) before cmap, so a font that ships
19//!   the PF-B block (DejaVuSans, Noto Sans Arabic, Amiri) renders
20//!   visually-correct contextual shapes — including LAM-ALEF
21//!   ligatures via the existing GSUB pass.
22//! - **Indic + Brahmic complex-script shaping (rounds 8 + 10 + 11 +
23//!   12 + 13)** — `shaping::indic` classifies Devanagari
24//!   (U+0900..U+097F), Bengali (U+0980..U+09FF), Tamil
25//!   (U+0B80..U+0BFF), Gurmukhi (U+0A00..U+0A7F), Gujarati
26//!   (U+0A80..U+0AFF), Telugu (U+0C00..U+0C7F), Kannada
27//!   (U+0C80..U+0CFF), Malayalam (U+0D00..U+0D7F), Oriya
28//!   (U+0B00..U+0B7F), Sinhala (U+0D80..U+0DFF), Khmer
29//!   (U+1780..U+17FF), Thai (U+0E00..U+0E7F), Lao (U+0E80..U+0EFF),
30//!   and Myanmar / Burmese (U+1000..U+109F) codepoints into syllabic
31//!   categories, segments runs into orthographic clusters, and
32//!   applies per-script cluster transformations: pre-base matra
33//!   reorder (a uniform mechanism across all scripts that have one)
34//!   plus reph identification (the Indic core scripts; Tamil +
35//!   Malayalam + Sinhala + Khmer + Thai + Lao are reph-disabled;
36//!   Burmese identifies a kinzi NGA+Asat+Virama+Cons instead via
37//!   `RephKind::BurmeseKinzi`). Khmer's halant role is played by
38//!   U+17D2 COENG which stacks subjoined consonants underneath the
39//!   base; Thai + Lao have no halant and their pre-base vowels are
40//!   already in storage order before their consonant. The
41//!   `FaceChain::shape` pipeline applies the reorder before cmap so
42//!   cmap-only fonts render simple clusters with the matra in the
43//!   correct visual position. When the active face publishes a
44//!   `rphf` GSUB lookup for the script, identified reph clusters
45//!   get the leading RA glyph substituted to its reph-form and the
46//!   halant glyph dropped via `Font::gsub_apply_lookup_type_1`.
47//!   Round 11 wires cluster-position GSUB features (`half` / `pref`
48//!   / `blwf` / `abvf` / `pstf` / `pres` / `psts` / `abvs` / `blws`).
49//!   Round 13 adds a multi-glyph context-aware GSUB pass dispatching
50//!   `locl` / `nukt` / `akhn` / `cjct` / `init` / `haln` via
51//!   `Font::gsub_apply_lookup_type_5` (Contextual) +
52//!   `gsub_apply_lookup_type_6` (Chained Context).
53//! - **Variable fonts (rounds 9 + 14)** — `Face::set_variation_coords`
54//!   / `variation_axes` / `named_instances` / `is_variable` surface
55//!   the font's `fvar` declarations; outlines flow through
56//!   `oxideav-ttf`'s gvar interpolator. `Shaper::with_variation_coords`
57//!   is the per-call override. Round 14 closes the metric-variation
58//!   gap: `Face::mvar()` / `metric_delta(tag)`, `Face::hvar()` /
59//!   `h_advance_delta(gid)`, `Face::vvar()` / `v_advance_delta(gid)`,
60//!   `Face::stat()` / `stat_axes()` / `stat_axis_values()` (with
61//!   `name_id(nid)` resolving the human-readable label).
62//!   `Face::cff2()` parses the CFF2 INDEX walker for presence + axis
63//!   count + glyph count; full Type 2 v3 charstring evaluation with
64//!   the `blend` operator is deferred.
65//! - **`Face::glyph_path` / `glyph_node`** — TrueType + OTF (CFF)
66//!   outlines as `oxideav_core::Path`; CBDT/sbix colour bitmaps as
67//!   `Node::Image` carrying a `VideoFrame`.
68//! - **`Shaper::shape_to_paths`** — vector text API: positioned
69//!   `(face_idx, Node, Transform2D)` triples ready to compose into a
70//!   `VectorFrame`. Each glyph is wrapped in a cache-keyed `Group` so
71//!   the downstream rasterizer's bitmap cache reuses the same memoised
72//!   glyph across renders.
73//! - **Face chain** — multi-face fallback (primary → fallback chain),
74//!   per-codepoint resolution.
75//! - **Layout** — line measurement + word-wrap (no bidi).
76//! - **BiDi (rounds 186 + 191)** — `bidi::bidi_class(c)` returns the
77//!   UAX #9 §3.2 normative bidirectional class for every code point
78//!   scribe needs today (the 12 explicit-format controls, ASCII /
79//!   Latin-1, Hebrew, four core Arabic blocks plus Syriac, Thaana,
80//!   N'Ko, the two Arabic Presentation Forms blocks, and combining-
81//!   mark NSMs); `bidi::paragraph_level(text)` implements UAX #9
82//!   rules P1 / P2 / P3, returning the paragraph embedding level
83//!   (0 = LTR, 1 = RTL). `bidi::split_paragraphs(text)` is P1's
84//!   split (every type-`B` character is kept with the previous
85//!   paragraph). Round 191 lands `bidi::resolve_weak_types(classes,
86//!   sos, eos)`, the §3.3.4 weak-type resolution pass (rules
87//!   W1..W7) operating on one isolating run sequence in place: NSM
88//!   inheritance (W1), EN-after-AL → AN (W2), AL → R (W3),
89//!   single-separator-between-two-numbers collapse (W4),
90//!   ET-adjacent-to-EN collapse (W5), leftover-separator
91//!   neutralisation (W6), and EN-after-L → L (W7). Round 198 lands
92//!   `bidi::resolve_neutral_types(classes, embedding_level, sos,
93//!   eos)`, the §3.3.5 neutral / isolate-formatting resolution
94//!   pass (rules N1 + N2): every maximal NI run (B / S / WS / ON /
95//!   LRI / RLI / FSI / PDI) collapses to a strong direction —
96//!   matching neighbours via N1 (with `EN` / `AN` counting as `R`)
97//!   or the embedding direction via N2. Round 204 lands
98//!   `bidi::resolve_implicit_levels(classes, embedding_level)`, the
99//!   §3.3.6 implicit-level pass (rules I1 + I2): every character's
100//!   resolved type drives a per-character level adjustment per
101//!   UAX #9 Table 5 (L stays at even, R goes +1; AN / EN go +2;
102//!   under odd, L / EN / AN all go +1). Round 210 lands the §3.4
103//!   line-level pair: `bidi::reset_trailing_levels(orig_classes,
104//!   levels, paragraph_level)` is rule **L1** (separator + trailing
105//!   whitespace + isolate-formatting characters reset back to the
106//!   paragraph embedding level using the *original* class slice
107//!   per §3.4) and `bidi::reorder_line(levels) -> Vec<usize>` is
108//!   rule **L2** (the progressive top-down reversal that produces
109//!   a logical-to-visual permutation a renderer walks to emit
110//!   glyphs in display order). Round 217 lands
111//!   `bidi::resolve_explicit_levels(classes, paragraph_level)`, the
112//!   §3.3.2 explicit-level / override / isolate stack pass (rules
113//!   X1..X9): produces a per-character embedding level + an
114//!   override-rewritten effective-class slice + the X9-removed-
115//!   character flag set, ready for X10's isolating-run-sequence
116//!   partition to feed the W / N / I phases. Round 227 lands
117//!   `bidi::process_paragraph(text, base_level)` and the
118//!   class-driven mirror `bidi::process_paragraph_classes(classes,
119//!   base_level)`, the §3 whole-paragraph driver that composes
120//!   P → X → W → N → I in one call and returns a
121//!   `ParagraphBidi` carrier carrying the paragraph level + the
122//!   original classes + the X9-removed flag set + the resolved
123//!   per-character level vector. The carrier exposes
124//!   `reorder_paragraph()` (whole-paragraph L1 + L2 convenience)
125//!   and `reorder_line_range(start..end)` (per-line variant for
126//!   callers that have a line-breaker). Round 247 lands
127//!   `bidi::reorder_combining_marks(orig_classes, levels,
128//!   &mut visual)`, the §3.4 rule **L3** in-place permutation
129//!   adjuster: every L2-reversed `[NSM, …, NSM, base]` block in
130//!   the visual stream (identified by its strictly-decreasing
131//!   logical indices) is reversed back to `[base, NSM, …, NSM]`
132//!   so callers running a non-scribe mark-attachment policy get
133//!   the spec's "expects them to follow" alternative shape; the
134//!   function is idempotent and ignores even-level (LTR) runs.
135//!   Round 257 lands the §3.3.5 **N0 bracket-pair pass**:
136//!   `bidi::paired_bracket(c)` is the BD14 / BD15 lookup for the
137//!   six ASCII brackets (`(` ↔ `)`, `[` ↔ `]`, `{` ↔ `}`);
138//!   `bidi::bracket_pairs(chars, classes)` runs the BD16 stack
139//!   walk (63-deep, overflow → empty list, sort by opener);
140//!   `bidi::resolve_bracket_pairs(classes, pairs, embedding,
141//!   sos)` applies the N0 a / b / c / d cases in place (EN/AN
142//!   counted as R for the inside-strong + preceding-strong walks,
143//!   sequential ordering so inner pairs see the rewrites of outer
144//!   ones, trailing-NSM inheritance per the §3.3.5 note); and
145//!   `bidi::process_paragraph_with_brackets(text, base_level)` /
146//!   `process_paragraph_classes_with_brackets(classes, chars,
147//!   base_level)` are the §3 paragraph driver with N0 wired in
148//!   between W7 and N1. The full Unicode `BidiBrackets.txt`
149//!   table (the ~60 paired-bracket entries across the
150//!   Mathematical Operators / CJK Symbols / Ornamental Brackets
151//!   blocks) is not yet vendored under `docs/text/unicode-bidi/`;
152//!   non-ASCII bracket pairs are deferred until the table lands.
153//!   Round 268 lands the §3.4 **L4 mirroring rule**:
154//!   `bidi::mirrored_glyph(c)` is the `Bidi_Mirroring_Glyph`
155//!   acceptable-mirror-pair lookup for the same six ASCII brackets
156//!   (an involution, `None` outside the seed set including the
157//!   §3.4-excluded ornate parentheses U+FD3E / U+FD3F), and
158//!   `bidi::apply_mirroring(chars, levels)` rewrites every
159//!   odd-resolved-level (directionality R) position that has a
160//!   mirror pair in place over the line's logical character
161//!   sequence. The wider `BidiMirroring.txt` pair table
162//!   (mathematical operators, angle brackets, CJK bracket blocks)
163//!   is deferred until that data file is vendored.
164//!
165//! See `README.md` for a tour and the deferral list.
166
167#![deny(missing_debug_implementations)]
168#![warn(rust_2018_idioms)]
169
170pub mod bidi;
171pub mod color;
172pub mod color_glyph;
173pub mod face;
174pub mod face_chain;
175pub mod layout;
176pub mod shaper;
177pub mod shaping;
178pub mod style;
179pub mod variations;
180
181pub use bidi::{
182    apply_mirroring, bidi_class, bracket_pairs, isolating_run_sequences, level_runs,
183    mirrored_glyph, paired_bracket, paragraph_level, process_paragraph, process_paragraph_classes,
184    process_paragraph_classes_with_brackets, process_paragraph_with_brackets, process_text,
185    reorder_combining_marks, reorder_line, reset_trailing_levels, resolve_bracket_pairs,
186    resolve_explicit_levels, resolve_implicit_levels, resolve_neutral_types, resolve_weak_types,
187    split_paragraphs, BidiClass, BracketKind, ExplicitLevels, IsolatingRunSequence, LevelRun,
188    ParagraphBidi, ParagraphSlice, TextBidi, MAX_DEPTH,
189};
190pub use color::{Rgba, TRANSPARENT, WHITE};
191pub use color_glyph::ColorGlyphBitmap;
192pub use face::{Face, FaceKind};
193pub use face_chain::FaceChain;
194pub use layout::{reorder_line_visual, run_width, wrap_lines, VisualLine};
195pub use oxideav_ttf::{NamedInstance, VariationAxis};
196pub use shaper::{PositionedGlyph, Shaper, ShaperBuilder};
197pub use shaping::{
198    bengali_category, bengali_feature_tags, burmese_category, burmese_feature_tags,
199    cluster_boundaries, cluster_boundaries_with, compute_forms, devanagari_category,
200    devanagari_feature_tags, feature_tags_for_run, gujarati_category, gujarati_feature_tags,
201    gurmukhi_category, gurmukhi_feature_tags, joining_class, kannada_category,
202    kannada_feature_tags, khmer_category, khmer_feature_tags, lao_category, lao_feature_tags,
203    malayalam_category, malayalam_feature_tags, oriya_category, oriya_feature_tags,
204    presentation_form, reorder_cluster, reorder_cluster_with, script_indic_tags,
205    shape_text_with_font, shape_text_with_script_with_font, sinhala_category, sinhala_feature_tags,
206    tamil_category, tamil_feature_tags, telugu_category, telugu_feature_tags, thai_category,
207    thai_feature_tags, ClusterFlags, IndicCategory, JoiningClass, JoiningForm, ReorderRules,
208    RephKind, Script, BENGALI_RULES, BURMESE_RULES, DEVANAGARI_RULES, GUJARATI_RULES,
209    GURMUKHI_RULES, KANNADA_RULES, KHMER_RULES, LAO_RULES, MALAYALAM_RULES, ORIYA_RULES,
210    SINHALA_RULES, TAMIL_RULES, TELUGU_RULES, THAI_RULES,
211};
212pub use style::{
213    synthetic_italic_shear, Style, DEFAULT_SYNTHETIC_ITALIC_DEG, ITALIC_ANGLE_EPSILON_DEG,
214};
215
216/// Errors emitted by the scribe pipeline.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum Error {
219    /// The underlying TTF parser rejected the bytes.
220    Ttf(oxideav_ttf::Error),
221    /// The underlying OTF (CFF) parser rejected the bytes.
222    Otf(oxideav_otf::Error),
223    /// `size_px` was non-positive (negative or NaN).
224    InvalidSize,
225    /// A `with_font` / `with_otf_font` call was made on a face of
226    /// the wrong flavour.
227    WrongFaceKind {
228        expected: FaceKind,
229        actual: FaceKind,
230    },
231}
232
233impl From<oxideav_ttf::Error> for Error {
234    fn from(e: oxideav_ttf::Error) -> Self {
235        Self::Ttf(e)
236    }
237}
238
239impl From<oxideav_otf::Error> for Error {
240    fn from(e: oxideav_otf::Error) -> Self {
241        Self::Otf(e)
242    }
243}
244
245impl core::fmt::Display for Error {
246    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
247        match self {
248            Self::Ttf(e) => write!(f, "ttf error: {e}"),
249            Self::Otf(e) => write!(f, "otf error: {e}"),
250            Self::InvalidSize => f.write_str("non-positive font size"),
251            Self::WrongFaceKind { expected, actual } => {
252                write!(f, "wrong face kind: expected {expected:?}, got {actual:?}")
253            }
254        }
255    }
256}
257
258impl std::error::Error for Error {
259    /// Expose the underlying parser error for the `Ttf` / `Otf`
260    /// variants so callers consuming `dyn Error` (or printing the
261    /// chain via `anyhow` / `thiserror`'s `source()` walker) see the
262    /// real diagnostic rather than just the wrapper. The leaf
263    /// variants (`InvalidSize`, `WrongFaceKind`) have no inner
264    /// source.
265    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
266        match self {
267            Self::Ttf(e) => Some(e),
268            Self::Otf(e) => Some(e),
269            Self::InvalidSize | Self::WrongFaceKind { .. } => None,
270        }
271    }
272}
273
274#[cfg(test)]
275mod error_tests {
276    use super::*;
277    use std::error::Error as _;
278
279    #[test]
280    fn leaf_variants_have_no_source() {
281        let e = Error::InvalidSize;
282        assert!(e.source().is_none());
283        let e = Error::WrongFaceKind {
284            expected: FaceKind::Ttf,
285            actual: FaceKind::Otf,
286        };
287        assert!(e.source().is_none());
288    }
289
290    #[test]
291    fn display_emits_human_readable_text() {
292        let e = Error::InvalidSize;
293        assert_eq!(format!("{e}"), "non-positive font size");
294        let e = Error::WrongFaceKind {
295            expected: FaceKind::Ttf,
296            actual: FaceKind::Otf,
297        };
298        let s = format!("{e}");
299        assert!(s.starts_with("wrong face kind"), "got {s}");
300    }
301
302    #[test]
303    fn ttf_wrap_round_trips_via_from() {
304        // Constructing a real `oxideav_ttf::Error` requires triggering
305        // the upstream parser, which we exercise indirectly through
306        // `Face::from_ttf_bytes` in the face module. Here we only
307        // assert the `From` arm matches the variant shape — feeding
308        // an obviously-invalid byte slice through a Face load is what
309        // the integration tests already cover.
310        let err: Result<Face, Error> = Face::from_ttf_bytes(vec![0u8, 1, 2, 3]);
311        match err {
312            Err(Error::Ttf(inner)) => {
313                let _ = inner; // confirms variant + that it carries the inner
314            }
315            Ok(_) => panic!("4-byte garbage should not parse as TTF"),
316            Err(other) => panic!("expected Error::Ttf, got {other:?}"),
317        }
318    }
319
320    #[test]
321    fn otf_wrap_round_trips_via_from() {
322        let err: Result<Face, Error> = Face::from_otf_bytes(vec![0u8, 1, 2, 3]);
323        match err {
324            Err(Error::Otf(inner)) => {
325                let _ = inner;
326            }
327            Ok(_) => panic!("4-byte garbage should not parse as OTF"),
328            Err(other) => panic!("expected Error::Otf, got {other:?}"),
329        }
330    }
331}