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}