From 7a33acd395977ec275d64eb15f63146a4c25de69 Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Sun, 3 May 2026 10:02:14 +0200 Subject: [PATCH] glyph protocol implementation (#1566) * glyph protocol implementation originally made in #1542 * fixup! glyph protocol implementation originally made in #1542 * simplify stuff * a bit of cleanup * implement query * fix self.route_id * few fixes --- frontends/rioterm/src/application.rs | 48 + frontends/rioterm/src/grid_emit.rs | 211 ++- frontends/rioterm/src/renderer/font_cache.rs | 6 +- frontends/rioterm/src/screen/mod.rs | 1 + rio-backend/src/ansi/glyph_protocol.rs | 1273 +++++++++++++++++ rio-backend/src/ansi/mod.rs | 1 + rio-backend/src/crosswords/mod.rs | 277 ++++ rio-backend/src/event/mod.rs | 28 + rio-backend/src/performer/handler.rs | 103 ++ sugarloaf/src/font/glyf_decode.rs | 435 ++++++ sugarloaf/src/font/glyph_registry.rs | 578 ++++++++ sugarloaf/src/font/mod.rs | 174 ++- sugarloaf/src/font_cache.rs | 5 +- sugarloaf/src/lib.rs | 14 + .../src/renderer/image_cache/colr_raster.rs | 1077 ++++++++++++++ sugarloaf/src/renderer/image_cache/mod.rs | 1 + sugarloaf/src/text.rs | 5 +- 17 files changed, 4218 insertions(+), 19 deletions(-) create mode 100644 rio-backend/src/ansi/glyph_protocol.rs create mode 100644 sugarloaf/src/font/glyf_decode.rs create mode 100644 sugarloaf/src/font/glyph_registry.rs create mode 100644 sugarloaf/src/renderer/image_cache/colr_raster.rs diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 07ea326a..a6f9096e 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -429,8 +429,56 @@ impl ApplicationHandler for Application<'_> { } } } + RioEventType::Rio(RioEvent::GlyphProtocolInstalled { + route_id, + registry, + }) => { + if let Some(route) = self.router.routes.get(&window_id) { + route + .window + .screen + .sugarloaf + .font_library() + .install_glyph_registry(route_id, registry); + } + } + RioEventType::Rio(RioEvent::GlyphProtocolQuery { route_id, cp }) => { + if let Some(route) = self.router.routes.get_mut(&window_id) { + use rio_backend::ansi::glyph_protocol::{ + format_query_response, QueryStatus, + }; + let library = route.window.screen.sugarloaf.font_library(); + let in_glossary = library + .glyph_registry_for(route_id) + .is_some_and(|r| r.contains(cp)); + let in_system = library.covers_codepoint(cp); + let status = match (in_glossary, in_system) { + (true, true) => QueryStatus::Both, + (true, false) => QueryStatus::Glossary, + (false, true) => QueryStatus::System, + (false, false) => QueryStatus::Free, + }; + let resp = format_query_response(cp, status); + if let Some(item) = route + .window + .screen + .context_manager + .current_grid_mut() + .get_by_route_id(route_id) + { + item.context_mut().messenger.send_bytes(resp.into_bytes()); + } + } + } RioEventType::Rio(RioEvent::CloseTerminal(route_id)) => { if let Some(route) = self.router.routes.get_mut(&window_id) { + route + .window + .screen + .sugarloaf + .font_library() + .remove_glyph_registry(route_id); + if route .window .screen diff --git a/frontends/rioterm/src/grid_emit.rs b/frontends/rioterm/src/grid_emit.rs index 4d1182ce..6a134b92 100644 --- a/frontends/rioterm/src/grid_emit.rs +++ b/frontends/rioterm/src/grid_emit.rs @@ -324,6 +324,13 @@ enum DecorationStyle { /// `font.sprite_index` idea. const DECORATION_FONT_ID_BASE: u32 = 0xFFFF_FF00; +/// Sentinel font_id for Glyph Protocol registrations. Pulled directly +/// in u32 form from `sugarloaf::font::glyph_registry`; lands above the +/// cursor/decoration ranges and never collides with a real font index. +/// The atlas `glyph_id` for a registered cell is +/// `pack_atlas_glyph_id(codepoint, version)`. +use rio_backend::sugarloaf::font::glyph_registry::CUSTOM_GLYPH_FONT_ID_U32; + /// Sentinel font_id base for cursor sprites. Distinct from the /// decoration range so the two never collide in the atlas /// hash-key space. @@ -1039,7 +1046,14 @@ struct RunCacheEntry { } pub struct GridGlyphRasterizer { - font_resolve: FxHashMap<(char, u8), (u32, bool)>, + /// Cache of `(char, style_flags, route_id) → (font_id, is_emoji)` + /// resolutions. The route_id is part of the key because Glyph + /// Protocol registrations are per-pane: the same PUA codepoint + /// can resolve to `CUSTOM_GLYPH_FONT_ID` in one pane and to a + /// system font in another. Non-PUA characters resolve identically + /// across panes; the duplication is cheap (a few bytes per + /// (char, route) pair) compared to the cost of mis-rendering. + font_resolve: FxHashMap<(char, u8, usize), (u32, bool)>, ascent_cache: FxHashMap<(u32, u16), i16>, /// `(should_embolden, should_italicize)` per font_id. Read from /// `FontData` synthesis flags; matches the rich-text rasterizer's @@ -1121,6 +1135,7 @@ impl GridGlyphRasterizer { ch: char, style_flags: u8, font_library: &FontLibrary, + route_id: usize, ) -> (u32, bool) { // Kitty Unicode placeholder cells (U+10EEEE) are rendered as // image-overlay slices, not text. Resolve them to the primary @@ -1133,8 +1148,9 @@ impl GridGlyphRasterizer { // ASCII printable + regular style → always primary font, never // emoji. Skips the FxHashMap lookup that dominates this fn's - // cost on terminal-typical content. - // `font/Group.zig` indexForCodepoint ASCII fast path. + // cost on terminal-typical content. ASCII codepoints can never + // be Glyph-Protocol-registered (PUA-only restriction), so the + // route_id is irrelevant on this fast path. // // Bold / italic ASCII still goes through the cache because // the bold and italic font IDs are dynamic (depend on which @@ -1145,15 +1161,16 @@ impl GridGlyphRasterizer { *self .font_resolve - .entry((ch, style_flags)) + .entry((ch, style_flags, route_id)) .or_insert_with(|| { let span_style = span_style_for_flags(style_flags); #[cfg(target_os = "macos")] - let (id, emoji) = font_library.resolve_font_for_char(ch, &span_style); + let (id, emoji) = + font_library.resolve_font_for_char(ch, &span_style, Some(route_id)); #[cfg(not(target_os = "macos"))] let (id, emoji) = { let lib = font_library.inner.read(); - lib.find_best_font_match(ch, &span_style) + lib.find_best_font_match(ch, &span_style, Some(route_id)) .unwrap_or((0, false)) }; (id as u32, emoji) @@ -1384,6 +1401,7 @@ pub fn build_row_fg( row_sel: Option, row_hints: &[RowHint], font_library: &FontLibrary, + route_id: usize, fg_scratch: &mut Vec, ) { fg_scratch.clear(); @@ -1403,6 +1421,15 @@ pub fn build_row_fg( let has_color_hints = row_hints.iter().any(|rh| rh.tag != HintTag::HyperlinkHover); let needs_per_cell_check = has_sel || has_color_hints; + // Glyph Protocol registry for *this pane*. One Arc clone per row; + // the per-cell custom-glyph helper then uses the local handle so + // it never re-acquires the FontLibrary read lock. Arc clone is + // cheap; `None` when no program in this pane's session has used + // the protocol. With multiple panes, each pane consults its own + // registry by route_id, so two panes can register conflicting + // glyphs at the same codepoint without interfering. + let glyph_registry = font_library.glyph_registry_for(route_id); + // Phase 1: underline pass. Emit before glyphs so grayscale quads // draw under the characters. emit_underlines( @@ -1435,7 +1462,94 @@ pub fn build_row_fg( let run_style_flags = (style_set.get(run_start_style_id).flags.bits() & SHAPING_FLAG_MASK) as u8; let (font_id, is_emoji) = - rasterizer.resolve_font(ch, run_style_flags, font_library); + rasterizer.resolve_font(ch, run_style_flags, font_library, route_id); + + // Glyph Protocol short-circuit: registered codepoints render + // directly from the registry without shaping, run-extension, + // or per-platform shaper plumbing. Each registered cell is + // its own one-cell run. + if font_id == CUSTOM_GLYPH_FONT_ID_U32 { + // The font cascade reported a custom glyph but the row + // already cloned `glyph_registry` as None — registry was + // detached between font resolution and this branch (rare, + // but harmless: render nothing). + let Some(registry) = glyph_registry.as_ref() else { + x += 1; + continue; + }; + + // Borrow the primary font's ascent at this size if the + // run-shaper has populated it; otherwise approximate at + // 80% of the glyph size. The approximation only fires + // when no regular text has been laid out at this size yet + // — once the user types real text the cache fills and + // subsequent registered cells use the precise ascent. + let ascent_px = rasterizer + .ascent_cache + .get(&( + rio_backend::sugarloaf::font::FONT_ID_REGULAR as u32, + size_bucket, + )) + .copied() + .unwrap_or_else(|| (size_u16 as i16).saturating_mul(4) / 5); + + // fg colour, mirroring the regular emit loop's + // selection / hint precedence. + let color = if !needs_per_cell_check { + cell_fg(sq, style_set, renderer, term_colors) + } else { + let is_sel = cell_in_row_sel(row_sel, x as u16); + let hint_tag = if is_sel { + None + } else { + cell_in_row_hints(row_hints, x as u16) + }; + if is_sel { + cell_fg_selected(sq, style_set, renderer, term_colors) + } else if let Some(tag) = hint_tag { + cell_fg_hinted(tag, renderer) + } else { + cell_fg(sq, style_set, renderer, term_colors) + } + }; + + if let Some((_, slot, is_color)) = ensure_custom_glyph_by_codepoint( + grid, + registry, + ch as u32, + size_bucket, + size_u16, + cell_h, + ascent_px, + color, + ) { + if slot.w != 0 && slot.h != 0 { + // Colour atlas entries are pre-painted (palette + // applied during COLR rasterisation), so the + // shader multiplies by white. Mono entries take + // the per-cell fg colour the run loop computed + // above. + let (atlas, color) = if is_color { + (CellText::ATLAS_COLOR, [255, 255, 255, 255]) + } else { + (CellText::ATLAS_GRAYSCALE, color) + }; + fg_scratch.push(CellText { + glyph_pos: [slot.x as u32, slot.y as u32], + glyph_size: [slot.w as u32, slot.h as u32], + bearings: [slot.bearing_x, slot.bearing_y], + grid_pos: [x as u16, y], + color, + atlas, + bools: 0, + _pad: [0, 0], + }); + } + } + x += 1; + continue; + } + let run_start = x; // Sticky style_id — typical syntax-highlighted output has long // stretches of cells sharing one style_id. While it stays equal @@ -1490,7 +1604,7 @@ pub fn build_row_fg( } let ch2 = sq2.c(); let (font_id2, _) = - rasterizer.resolve_font(ch2, run_style_flags, font_library); + rasterizer.resolve_font(ch2, run_style_flags, font_library, route_id); if font_id2 != font_id { break; } @@ -1907,6 +2021,87 @@ fn ensure_glyph_by_id( Some((key, slot, is_color)) } +/// Look up or rasterise a Glyph Protocol registration into the grid +/// atlas. The atlas key combines the codepoint with the registration's +/// `version` (bumped on every register/clear) so re-registering the +/// same codepoint never serves a stale rasterisation. Each unique +/// (codepoint × version × pixel size) combination owns one atlas slot; +/// previous-version slots become unreachable and the atlas LRU evicts +/// them in due course. +/// +/// `ascent_px` matches the primary font's ascent at the same size +/// bucket — Glyph Protocol payloads have no font-of-their-own, so we +/// align registered glyphs to the surrounding text baseline. A more +/// faithful rendering would walk the registered outline's bbox to +/// compute per-glyph bearings, but for icon-style PUA glyphs the +/// primary-font baseline produces the expected appearance. +/// +/// `registry` is the active terminal's glyph registry, cloned once +/// per row by `build_row_fg`. Passing it in (instead of going through +/// the `FontLibrary` write lock) keeps the per-cell hot loop allocation +/// and lock free. +/// +/// Returns `None` when the registration was cleared between font +/// resolution and render, or when rasterisation produces no pixels +/// (zero-area outline, malformed COLR, etc.). +#[allow(clippy::too_many_arguments)] +fn ensure_custom_glyph_by_codepoint( + grid: &mut GridRenderer, + registry: &rio_backend::sugarloaf::font::glyph_registry::GlyphRegistry, + codepoint: u32, + size_bucket: u16, + size_u16: u16, + cell_h: f32, + ascent_px: i16, + foreground_rgba: [u8; 4], +) -> Option<(GlyphKey, AtlasSlot, bool)> { + use rio_backend::sugarloaf::font::glyph_registry::pack_atlas_glyph_id; + + // Fetch first so we know the registration's version. The lookup + // happens under the registry's RwLock read; the entry's payload is + // cloned out so the lock drops before we hit tiny-skia. + let entry = registry.get(codepoint)?; + let key = GlyphKey { + font_id: CUSTOM_GLYPH_FONT_ID_U32, + glyph_id: pack_atlas_glyph_id(codepoint, entry.version), + size_bucket, + }; + if let Some(slot) = grid.lookup_glyph(key) { + return Some((key, slot, false)); + } + if let Some(slot) = grid.lookup_glyph_color(key) { + return Some((key, slot, true)); + } + + let raster = rio_backend::sugarloaf::glyph_protocol::rasterize_payload( + &entry.payload, + entry.upm, + size_u16, + foreground_rgba, + )?; + + let bearing_y = { + let cell_h_i16 = cell_h.round().clamp(0.0, i16::MAX as f32) as i16; + cell_h_i16 + .saturating_sub(ascent_px) + .saturating_add(raster.top.clamp(i16::MIN as i32, i16::MAX as i32) as i16) + }; + let raster_in = RasterizedGlyph { + width: raster.width, + height: raster.height, + bearing_x: raster.left.clamp(i16::MIN as i32, i16::MAX as i32) as i16, + bearing_y, + bytes: &raster.data, + }; + + let slot = if raster.is_color { + grid.insert_glyph_color(key, raster_in)? + } else { + grid.insert_glyph(key, raster_in)? + }; + Some((key, slot, raster.is_color)) +} + /// Platform-agnostic raw-glyph struct. Both backends populate this /// shape and let the caller convert bearings to the grid's /// cell-bottom-relative convention. diff --git a/frontends/rioterm/src/renderer/font_cache.rs b/frontends/rioterm/src/renderer/font_cache.rs index 2db137bf..31c7b50a 100644 --- a/frontends/rioterm/src/renderer/font_cache.rs +++ b/frontends/rioterm/src/renderer/font_cache.rs @@ -132,8 +132,12 @@ impl FontCache { }; let mut width = ch.width().unwrap_or(1) as f32; + // No route context here — this cache is for + // layout width measurement, not pane rendering. + // PUA codepoints fall through to the regular + // font lookup, which returns a sensible width. if let Some((font_id, is_emoji)) = - font_ctx.find_best_font_match(ch, &style) + font_ctx.find_best_font_match(ch, &style, None) { if is_emoji { width = 2.0; diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 33bb6ce8..070bf03c 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -3822,6 +3822,7 @@ impl Screen<'_> { row_sel, &hint_scratch, &font_library, + p.route_id, &mut fg_scratch, ); grid.write_row(y as u32, &bg_scratch, &fg_scratch); diff --git a/rio-backend/src/ansi/glyph_protocol.rs b/rio-backend/src/ansi/glyph_protocol.rs new file mode 100644 index 00000000..bbc3699d --- /dev/null +++ b/rio-backend/src/ansi/glyph_protocol.rs @@ -0,0 +1,1273 @@ +// Glyph Protocol wire parser. +// +// Protocol framing: +// ESC _ 25a1 ; [ ; key=value ]* [ ; ] ESC \ +// +// Verbs: +// s — advertise supported payload formats; also serves as protocol +// detection (any reply = protocol implemented) +// q — query the state of a codepoint (status=0..3 bit field) +// r — register a PUA codepoint with a glyph +// c — clear one PUA codepoint or every registration in this session +// +// Payload formats (selected via `fmt=` on the `r` verb): +// glyf — single monochrome OpenType simple-glyph outline. +// colrv0 — up to 16 flat-color layers; each layer is an sRGBA colour +// (or a "foreground" sentinel) plus a `glyf` outline; layers +// composite in painter-order. +// colrv1 — same layer model as colrv0 but each layer carries a paint: +// solid, linear gradient, radial gradient, or foreground. No +// affine transforms and no sweep gradients in v1. +// +// `cp` is always a single codepoint. For `r` and `c`, `cp` MUST be in +// one of the three Unicode Private Use Area ranges; otherwise the +// request is rejected with `reason=out_of_namespace`. `q` accepts any +// valid Unicode scalar value so applications can probe system-font +// coverage for codepoints they intend to register. + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + +/// Protocol identifier — the literal ASCII string `"25a1"` that +/// prefixes every Glyph Protocol APC body. Terminals MUST drop APC +/// messages whose body does not begin with this identifier. +pub const GLYPH_PROTOCOL_PREFIX: &[u8] = b"25a1"; + +/// Upper bound on a single registered payload, post-base64-decode. +/// Matches the 64 KiB limit in the spec; anything larger is rejected +/// with `payload_too_large`. +pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024; + +/// Bitfield of payload formats this build supports, returned in the +/// reply to the `s` verb. +/// bit 0 = `glyf` (OpenType simple glyphs) +/// bit 1 = `colrv0` (flat-color layered outlines) +/// bit 2 = `colrv1` (layered outlines with solid/linear/radial paints) +pub const SUPPORTED_FORMATS: u8 = 0b0000_0111; + +/// Check whether a codepoint is in any of the three Unicode Private +/// Use Areas. +#[inline] +pub fn is_pua(cp: u32) -> bool { + (0xE000..=0xF8FF).contains(&cp) // basic + || (0xF_0000..=0xF_FFFD).contains(&cp) // supplementary A + || (0x10_0000..=0x10_FFFD).contains(&cp) // supplementary B +} + +/// Parsed Glyph Protocol command, ready for dispatch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GlyphCommand { + /// Advertise supported payload formats. Parameter-free; doubles as + /// the protocol-detection ping. + Support, + /// Query state of a single codepoint. + Query { cp: u32 }, + /// Register a glyph at a PUA codepoint chosen by the client. The + /// `payload` carries format-specific data (monochrome `glyf`, or a + /// `colrv0`/`colrv1` colour container wrapping OpenType tables). + /// The `reply` level controls which replies (if any) the + /// dispatcher emits — see [`ReplyMode`] for the three tiers. + Register { + cp: u32, + payload: GlyphPayload, + reply: ReplyMode, + }, + /// Clear a single PUA codepoint (`Some`) or every slot (`None`). + Clear { cp: Option }, +} + +/// Upper bound on the number of glyph outlines carried in a single +/// colour payload. Keeps the glossary's decode cost bounded and sits +/// well within the 16-bit GlyphId namespace used by COLR. +const MAX_COLR_GLYPHS: u16 = 1024; + +/// Payload shipped with an `r` (register) request. +/// +/// `Glyf` is a single OpenType simple-glyph record, rendered in the +/// current foreground colour. +/// +/// `ColrV0` and `ColrV1` share a wire container ([`ColrContainer`]) — +/// a length-prefixed array of simple-glyph outlines plus raw OpenType +/// `COLR` and `CPAL` tables. The outer variant distinguishes the COLR +/// table version the terminal should expect (v0 is layer-only, v1 is +/// the full paint graph). Reusing the OpenType binary layout means +/// applications can slice existing fonts directly; the terminal uses +/// `ttf_parser::colr::Table` to walk the paint graph and our own +/// `glyf` decoder (same as `fmt=glyf`) for the leaf outlines. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GlyphPayload { + Glyf { glyf: Vec, upm: u16 }, + ColrV0 { container: ColrContainer, upm: u16 }, + ColrV1 { container: ColrContainer, upm: u16 }, +} + +/// Wire container for `fmt=colrv0` and `fmt=colrv1` payloads. +/// +/// Layout after base64-decode: +/// ```text +/// u16 BE n_glyphs +/// per glyph: +/// u16 BE glyf_len +/// glyf_len bytes (simple-glyph, same encoding as fmt=glyf) +/// u16 BE colr_len +/// colr_len bytes (OpenType COLR table, v0 or v1) +/// u16 BE cpal_len +/// cpal_len bytes (OpenType CPAL table; may be zero-length when +/// the COLR references only foreground / direct +/// sRGB values in the v1 paint graph) +/// ``` +/// +/// Glyph IDs in the COLR table resolve to indices into `glyphs`. +/// CPAL palette index `0xFFFF` means "current foreground colour", per +/// the OpenType spec. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColrContainer { + pub glyphs: Vec>, + pub colr: Vec, + pub cpal: Vec, +} + +/// Three-level reply control for the `r` verb, selected with the +/// `reply` parameter on a register request. The values mirror the +/// wire encoding (`reply=0` / `reply=1` / `reply=2`) so dispatchers +/// can skip a round of translation. +/// +/// Fire-and-forget bulk registrations should use [`ReplyMode::None`] +/// so `status=0` ACKs don't queue in the PTY and spill to the shell +/// when the client exits. Bulk registrations that want failure +/// telemetry without the success noise should use +/// [`ReplyMode::ErrorsOnly`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReplyMode { + /// `reply=0`: the dispatcher emits nothing for this registration. + None, + /// `reply=1` (default): the dispatcher emits both success (`status=0`) + /// and failure (`status=`) replies. The default when + /// `reply` is omitted or holds an unrecognised value. + #[default] + All, + /// `reply=2`: the dispatcher emits only failure replies, dropping + /// the `status=0` ACK on success. Handy for large bulk + /// registrations that want errors surfaced without the noise of + /// 256 ACKs on the happy path. + ErrorsOnly, +} + +impl ReplyMode { + /// Whether a successful register should emit `status=0`. + pub fn emit_success(self) -> bool { + matches!(self, ReplyMode::All) + } + /// Whether a failed register should emit `status=;reason=…`. + pub fn emit_error(self) -> bool { + matches!(self, ReplyMode::All | ReplyMode::ErrorsOnly) + } + + fn from_wire(raw: &[u8]) -> Self { + match raw { + b"0" => ReplyMode::None, + b"2" => ReplyMode::ErrorsOnly, + // `reply=1`, an unrecognised value, or an absent parameter + // all land here. Per §11 unknown-params rule the default + // behaviour (emit both) is the safe fallback. + _ => ReplyMode::All, + } + } +} + +/// Query status — two-bit field per spec §5.2. Bit 0: system coverage. +/// Bit 1: glossary coverage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryStatus { + Free = 0, + System = 1, + Glossary = 2, + Both = 3, +} + +impl QueryStatus { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// Defined register-error codes, per spec §6.2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegisterError { + OutOfNamespace, + CompositeUnsupported, + HintingUnsupported, + MalformedPayload, + PayloadTooLarge, +} + +impl RegisterError { + fn as_str(self) -> &'static str { + match self { + RegisterError::OutOfNamespace => "out_of_namespace", + RegisterError::CompositeUnsupported => "composite_unsupported", + RegisterError::HintingUnsupported => "hinting_unsupported", + RegisterError::MalformedPayload => "malformed_payload", + RegisterError::PayloadTooLarge => "payload_too_large", + } + } +} + +/// Error returned when the APC body is not a valid Glyph Protocol +/// message, or when wire-level validation rejects the request before +/// it reaches the handler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + /// Body does not start with `25a1` — not our protocol; caller + /// should fall through to other APC dispatchers. + NotGlyphProtocol, + /// Framing was recognised but malformed. + Malformed(&'static str), + /// Register rejected at parse time. Dispatcher formats this as + /// `status=; reason=` with the supplied `cp`, + /// unless the original `r` request carried a `reply` level that + /// disables error replies (see [`ReplyMode::emit_error`]). + RegisterFailed { + cp: u32, + reason: RegisterError, + reply: ReplyMode, + }, + /// `c;cp=` where the codepoint is not in any PUA range. + ClearOutOfNamespace, +} + +/// Parse a raw APC body (minus the `ESC _` introducer and `ESC \` +/// terminator) into a [`GlyphCommand`]. +pub fn parse(body: &[u8]) -> Result { + if !body.starts_with(GLYPH_PROTOCOL_PREFIX) { + return Err(ParseError::NotGlyphProtocol); + } + let rest = &body[GLYPH_PROTOCOL_PREFIX.len()..]; + let rest = rest + .strip_prefix(b";") + .ok_or(ParseError::Malformed("missing verb separator"))?; + + let (verb, rest) = split_once(rest, b';'); + let verb = trim(verb); + if verb.len() != 1 { + return Err(ParseError::Malformed("verb must be a single byte")); + } + match verb[0] { + b's' => parse_support(rest), + b'q' => parse_query(rest), + b'r' => parse_register(rest), + b'c' => parse_clear(rest), + _ => Err(ParseError::Malformed("unknown verb")), + } +} + +fn parse_support(_rest: &[u8]) -> Result { + // `s` takes no parameters. Per spec §11 conformance rule, unknown + // params are silently ignored rather than erroring out, so future + // clients that send extra hints (e.g. a client-advertised format + // preference) still get a valid reply from this implementation. + Ok(GlyphCommand::Support) +} + +fn parse_query(rest: &[u8]) -> Result { + let params = parse_params(rest); + let cp_raw = params + .get("cp") + .ok_or(ParseError::Malformed("query missing cp"))?; + if cp_raw.contains(&b',') { + return Err(ParseError::Malformed("cp must be a single codepoint")); + } + let cp = parse_hex_cp(cp_raw).ok_or(ParseError::Malformed("query cp invalid hex"))?; + Ok(GlyphCommand::Query { cp }) +} + +fn parse_register(rest: &[u8]) -> Result { + // Register splits control parameters from the base64 payload at + // the LAST `;`. Base64 has no `;` so this is unambiguous. + let (control, payload_b64) = split_last(rest, b';'); + let params = parse_params(control); + + let cp_raw = params + .get("cp") + .ok_or(ParseError::Malformed("register missing cp"))?; + if cp_raw.contains(&b',') { + return Err(ParseError::Malformed("cp must be a single codepoint")); + } + let cp = + parse_hex_cp(cp_raw).ok_or(ParseError::Malformed("register cp invalid hex"))?; + + // Extract `reply` before any can-fail validation so every error + // path below can honour the level. Unrecognised values fall back + // to the default (emit both success and failure replies). + let reply = params + .get("reply") + .map(|v| ReplyMode::from_wire(v)) + .unwrap_or_default(); + + // PUA check is the protocol's security contract — reject early so + // we don't bother decoding the payload. + if !is_pua(cp) { + return Err(ParseError::RegisterFailed { + cp, + reason: RegisterError::OutOfNamespace, + reply, + }); + } + + let fmt = params.get("fmt").copied().unwrap_or(b"glyf"); + if fmt != b"glyf" && fmt != b"colrv0" && fmt != b"colrv1" { + return Err(ParseError::Malformed("register fmt unknown")); + } + + let upm = match params.get("upm") { + Some(raw) => { + parse_decimal_u16(raw).ok_or(ParseError::Malformed("register upm invalid"))? + } + None => 1000, + }; + if upm == 0 { + return Err(ParseError::Malformed("register upm must be non-zero")); + } + + let payload_b64 = trim(payload_b64); + let raw = BASE64 + .decode(payload_b64) + .map_err(|_| ParseError::RegisterFailed { + cp, + reason: RegisterError::MalformedPayload, + reply, + })?; + if raw.len() > MAX_PAYLOAD_BYTES { + return Err(ParseError::RegisterFailed { + cp, + reason: RegisterError::PayloadTooLarge, + reply, + }); + } + // Empty payload is never a valid registration: a `glyf` outline + // needs at least the simple-glyph header bytes, and a colr + // container needs the `n_glyphs` u16 plus per-glyph data. An + // empty body usually means the trailing `;` segment was + // omitted altogether (e.g. `r;cp=E000;` with no payload), which + // would otherwise silently register an empty glyph for the slot. + if raw.is_empty() { + return Err(ParseError::RegisterFailed { + cp, + reason: RegisterError::MalformedPayload, + reply, + }); + } + + let payload = match fmt { + b"glyf" => GlyphPayload::Glyf { glyf: raw, upm }, + b"colrv0" => { + let container = parse_colr_container(&raw) + .map_err(|reason| ParseError::RegisterFailed { cp, reason, reply })?; + GlyphPayload::ColrV0 { container, upm } + } + b"colrv1" => { + let container = parse_colr_container(&raw) + .map_err(|reason| ParseError::RegisterFailed { cp, reason, reply })?; + GlyphPayload::ColrV1 { container, upm } + } + _ => unreachable!("fmt validated above"), + }; + + Ok(GlyphCommand::Register { cp, payload, reply }) +} + +/// Decode a `colrv0`/`colrv1` container (see [`ColrContainer`] doc for +/// the wire layout). Validation is structural only: the OpenType COLR +/// and CPAL tables are handed off to the renderer, which parses them +/// with `ttf_parser::colr::Table` when the glyph is rasterised — that +/// way any COLR-version-specific validation lives next to the code +/// that actually interprets it. +fn parse_colr_container(data: &[u8]) -> Result { + let mut cur = Cursor::new(data); + + let n_glyphs = cur.u16_be().ok_or(RegisterError::MalformedPayload)?; + if n_glyphs == 0 || n_glyphs > MAX_COLR_GLYPHS { + return Err(RegisterError::MalformedPayload); + } + + let mut glyphs: Vec> = Vec::with_capacity(n_glyphs as usize); + for _ in 0..n_glyphs { + let glyf_len = cur.u16_be().ok_or(RegisterError::MalformedPayload)? as usize; + let glyf = cur + .slice(glyf_len) + .ok_or(RegisterError::MalformedPayload)? + .to_vec(); + glyphs.push(glyf); + } + + let colr_len = cur.u16_be().ok_or(RegisterError::MalformedPayload)? as usize; + if colr_len == 0 { + return Err(RegisterError::MalformedPayload); + } + let colr = cur + .slice(colr_len) + .ok_or(RegisterError::MalformedPayload)? + .to_vec(); + + let cpal_len = cur.u16_be().ok_or(RegisterError::MalformedPayload)? as usize; + let cpal = cur + .slice(cpal_len) + .ok_or(RegisterError::MalformedPayload)? + .to_vec(); + + if cur.remaining() != 0 { + return Err(RegisterError::MalformedPayload); + } + + Ok(ColrContainer { glyphs, colr, cpal }) +} + +/// Minimal big-endian byte cursor. Used by the `colrv0`/`colrv1` +/// container parser — the OpenType tables nested inside are parsed by +/// `ttf-parser` downstream, so we only need enough here to carve out +/// their byte ranges. +struct Cursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + fn u16_be(&mut self) -> Option { + if self.pos + 2 > self.data.len() { + return None; + } + let hi = self.data[self.pos] as u16; + let lo = self.data[self.pos + 1] as u16; + self.pos += 2; + Some((hi << 8) | lo) + } + fn slice(&mut self, n: usize) -> Option<&'a [u8]> { + if self.pos + n > self.data.len() { + return None; + } + let s = &self.data[self.pos..self.pos + n]; + self.pos += n; + Some(s) + } +} + +fn parse_clear(rest: &[u8]) -> Result { + let params = parse_params(rest); + match params.get("cp") { + Some(cp_raw) => { + if cp_raw.contains(&b',') { + return Err(ParseError::Malformed("cp must be a single codepoint")); + } + let cp = parse_hex_cp(cp_raw) + .ok_or(ParseError::Malformed("clear cp invalid hex"))?; + if !is_pua(cp) { + return Err(ParseError::ClearOutOfNamespace); + } + Ok(GlyphCommand::Clear { cp: Some(cp) }) + } + None => Ok(GlyphCommand::Clear { cp: None }), + } +} + +/// Minimal parameter parser: semicolon-separated `key=value` pairs. +/// Keys are compared case-sensitively; unknown keys are silently kept +/// (callers ignore them, per spec §11 conformance rule). +fn parse_params(data: &[u8]) -> Params<'_> { + let mut out = Params::default(); + for part in data.split(|&b| b == b';') { + let part = trim(part); + if part.is_empty() { + continue; + } + if let Some(eq) = part.iter().position(|&b| b == b'=') { + let k = trim(&part[..eq]); + let v = trim(&part[eq + 1..]); + out.insert(k, v); + } + } + out +} + +/// Hex-parse a single codepoint (no leading `0x`, up to 6 digits). +fn parse_hex_cp(raw: &[u8]) -> Option { + let raw = trim(raw); + if raw.is_empty() || raw.len() > 6 { + return None; + } + let mut out: u32 = 0; + for &b in raw { + let d = match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + b'A'..=b'F' => b - b'A' + 10, + _ => return None, + } as u32; + out = (out << 4) | d; + } + if out > 0x10FFFF || (0xD800..=0xDFFF).contains(&out) { + return None; + } + Some(out) +} + +fn parse_decimal_u16(raw: &[u8]) -> Option { + let raw = trim(raw); + if raw.is_empty() { + return None; + } + let mut out: u32 = 0; + for &b in raw { + if !b.is_ascii_digit() { + return None; + } + out = out.checked_mul(10)?.checked_add((b - b'0') as u32)?; + if out > u16::MAX as u32 { + return None; + } + } + Some(out as u16) +} + +fn split_once(data: &[u8], sep: u8) -> (&[u8], &[u8]) { + if let Some(pos) = data.iter().position(|&b| b == sep) { + (&data[..pos], &data[pos + 1..]) + } else { + (data, &[]) + } +} + +fn split_last(data: &[u8], sep: u8) -> (&[u8], &[u8]) { + if let Some(pos) = data.iter().rposition(|&b| b == sep) { + (&data[..pos], &data[pos + 1..]) + } else { + (data, &[]) + } +} + +fn trim(data: &[u8]) -> &[u8] { + let mut start = 0; + let mut end = data.len(); + while start < end && matches!(data[start], b' ' | b'\t' | b'\r' | b'\n') { + start += 1; + } + while end > start && matches!(data[end - 1], b' ' | b'\t' | b'\r' | b'\n') { + end -= 1; + } + &data[start..end] +} + +#[derive(Default)] +struct Params<'a> { + entries: Vec<(&'a [u8], &'a [u8])>, +} + +impl<'a> Params<'a> { + fn insert(&mut self, k: &'a [u8], v: &'a [u8]) { + for e in &mut self.entries { + if e.0 == k { + e.1 = v; + return; + } + } + self.entries.push((k, v)); + } + + fn get(&self, k: &str) -> Option<&&'a [u8]> { + self.entries + .iter() + .find(|e| e.0 == k.as_bytes()) + .map(|e| &e.1) + } +} + +/// Format the reply to `s` (support). `fmt_bits` is the bitfield of +/// supported payload formats; bit 0 = `glyf`. A reply of `fmt=0` means +/// the protocol is implemented but no payload format is accepted — a +/// degenerate state reserved for future negotiation, never produced by +/// this build. +pub(crate) fn format_support_response(fmt_bits: u8) -> String { + format!("\x1b_25a1;s;fmt={}\x1b\\", fmt_bits) +} + +/// Format the reply to `q;cp=`. Public because the frontend +/// (`rioterm::application`) is the one that has access to both +/// `FontLibrary` and the per-route registry needed to compute the +/// status; it formats the reply itself and writes it back to the PTY. +pub fn format_query_response(cp: u32, status: QueryStatus) -> String { + format!("\x1b_25a1;q;cp={:x};status={}\x1b\\", cp, status.as_u8()) +} + +/// Format a successful register reply. +pub(crate) fn format_register_ok(cp: u32) -> String { + format!("\x1b_25a1;r;cp={:x};status=0\x1b\\", cp) +} + +/// Format a register error reply. +pub(crate) fn format_register_error(cp: u32, reason: RegisterError) -> String { + format!( + "\x1b_25a1;r;cp={:x};status=1;reason={}\x1b\\", + cp, + reason.as_str() + ) +} + +/// Format the reply to a clear request. `cp` is echoed back when the +/// request scoped to a single slot, omitted for "clear all". +pub(crate) fn format_clear_ok(cp: Option) -> String { + match cp { + Some(cp) => format!("\x1b_25a1;c;cp={:x};status=0\x1b\\", cp), + None => String::from("\x1b_25a1;c;status=0\x1b\\"), + } +} + +/// Format a clear error reply (currently only `out_of_namespace`). +pub(crate) fn format_clear_error_out_of_namespace() -> String { + String::from("\x1b_25a1;c;status=1;reason=out_of_namespace\x1b\\") +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + + fn b64(data: &[u8]) -> String { + BASE64.encode(data) + } + + #[test] + fn rejects_non_glyph_protocol_bodies() { + assert_eq!(parse(b"G,a=T;payload"), Err(ParseError::NotGlyphProtocol)); + assert_eq!(parse(b""), Err(ParseError::NotGlyphProtocol)); + } + + #[test] + fn is_pua_covers_all_three_ranges() { + assert!(is_pua(0xE000)); + assert!(is_pua(0xE0A0)); // Powerline branch + assert!(is_pua(0xF8FF)); // end of basic PUA + assert!(is_pua(0xF_0000)); // start of supp-A + assert!(is_pua(0xF_FFFD)); + assert!(is_pua(0x10_0000)); + assert!(is_pua(0x10_FFFD)); + } + + #[test] + fn is_pua_excludes_real_text_and_emoji() { + assert!(!is_pua(0x0061)); // 'a' + assert!(!is_pua(0x002D)); // '-' + assert!(!is_pua(0x1F600)); // grinning face — supplementary but NOT PUA + assert!(!is_pua(0xFFFE)); // noncharacter just before PUA-A + assert!(!is_pua(0xF_FFFE)); // noncharacter just after PUA-A + assert!(!is_pua(0x10_FFFF)); // noncharacter just after PUA-B + } + + #[test] + fn parses_query_single_codepoint() { + let got = parse(b"25a1;q;cp=E0A0").unwrap(); + assert_eq!(got, GlyphCommand::Query { cp: 0xE0A0 }); + } + + #[test] + fn query_accepts_non_pua_codepoints() { + // Query probes the world; it does not care about PUA. + let got = parse(b"25a1;q;cp=61").unwrap(); + assert_eq!(got, GlyphCommand::Query { cp: 0x61 }); + } + + #[test] + fn query_rejects_sequence() { + assert!(matches!( + parse(b"25a1;q;cp=2D,3E"), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn query_rejects_surrogate() { + assert!(matches!( + parse(b"25a1;q;cp=D800"), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn parses_register_at_pua_codepoint() { + let payload = b64(&[0x01, 0x02, 0x03]); + let body = format!("25a1;r;cp=E0A0;upm=1000;{}", payload); + let got = parse(body.as_bytes()).unwrap(); + assert_eq!( + got, + GlyphCommand::Register { + cp: 0xE0A0, + payload: GlyphPayload::Glyf { + glyf: vec![0x01, 0x02, 0x03], + upm: 1000, + }, + reply: ReplyMode::All, + } + ); + } + + #[test] + fn parses_register_with_explicit_fmt() { + let payload = b64(&[0xAA]); + let body = format!("25a1;r;cp=E0A0;fmt=glyf;upm=1000;{}", payload); + assert!(matches!( + parse(body.as_bytes()).unwrap(), + GlyphCommand::Register { + payload: GlyphPayload::Glyf { .. }, + .. + } + )); + } + + #[test] + fn register_defaults_upm_to_1000() { + let payload = b64(&[0x01]); + let body = format!("25a1;r;cp=E0A0;{}", payload); + let got = parse(body.as_bytes()).unwrap(); + if let GlyphCommand::Register { + payload: GlyphPayload::Glyf { upm, .. }, + .. + } = got + { + assert_eq!(upm, 1000); + } else { + panic!("expected glyf register"); + } + } + + #[test] + fn register_rejects_non_pua_codepoint() { + let payload = b64(&[0x01]); + let body = format!("25a1;r;cp=61;upm=1000;{}", payload); + assert_eq!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + cp: 0x61, + reason: RegisterError::OutOfNamespace, + reply: ReplyMode::All, + }) + ); + } + + #[test] + fn register_requires_cp() { + let payload = b64(&[0x01]); + let body = format!("25a1;r;upm=1000;{}", payload); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn register_accepts_each_pua_range() { + for &cp_hex in &[0xE0A0u32, 0xF_0000, 0x10_0000] { + let payload = b64(b"x"); + let body = format!("25a1;r;cp={:x};upm=1000;{}", cp_hex, payload); + assert!(matches!( + parse(body.as_bytes()).unwrap(), + GlyphCommand::Register { .. } + )); + } + } + + #[test] + fn register_rejects_unknown_fmt() { + let payload = b64(b"x"); + let body = format!("25a1;r;cp=E0A0;fmt=svg;upm=1000;{}", payload); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn register_rejects_bad_base64() { + let body = b"25a1;r;cp=E0A0;upm=1000;$$$$not_base64"; + assert!(matches!( + parse(body), + Err(ParseError::RegisterFailed { + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn register_rejects_oversized_payload() { + let payload = b64(&vec![0u8; MAX_PAYLOAD_BYTES + 1]); + let body = format!("25a1;r;cp=E0A0;upm=1000;{}", payload); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + reason: RegisterError::PayloadTooLarge, + .. + }) + )); + } + + #[test] + fn register_rejects_zero_upm() { + let payload = b64(b"x"); + let body = format!("25a1;r;cp=E0A0;upm=0;{}", payload); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn register_rejects_empty_payload() { + // Trailing `;` followed by an empty base64 segment. Without the + // post-decode empty check this would silently produce a + // `Glyf{glyf: vec![]}` registration that the renderer would + // later interpret as zero-byte garbage. + let body = b"25a1;r;cp=E0A0;upm=1000;"; + assert!(matches!( + parse(body), + Err(ParseError::RegisterFailed { + cp: 0xE0A0, + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn register_rejects_missing_payload_separator() { + // No `;` between control params and the would-be payload: the + // last-`;` split treats the whole tail as control, leaving the + // payload section empty. Same MalformedPayload as the trailing- + // semicolon case above. + let body = b"25a1;r;cp=E0A0"; + assert!(matches!( + parse(body), + Err(ParseError::RegisterFailed { + cp: 0xE0A0, + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn register_rejects_cp_above_unicode_max() { + // 6-hex-digit cps that overflow the 0x10FFFF Unicode ceiling + // must fail at hex parse time, not silently wrap. `parse_hex_cp` + // surfaces this as a generic `Malformed` since we no longer + // know which `cp` to attach to a `RegisterFailed`. + let payload = b64(b"x"); + let body = format!("25a1;r;cp=110000;upm=1000;{}", payload); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::Malformed(_)) + )); + + let body = format!("25a1;r;cp=FFFFFF;upm=1000;{}", payload); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn register_rejects_empty_cp_value() { + // `cp=` with no value: `parse_hex_cp` returns None on the empty + // slice, surfacing as Malformed("register cp invalid hex"). + let payload = b64(b"x"); + let body = format!("25a1;r;cp=;upm=1000;{}", payload); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn query_rejects_cp_above_unicode_max() { + let body = b"25a1;q;cp=110000"; + assert!(matches!(parse(body), Err(ParseError::Malformed(_)))); + } + + #[test] + fn clear_single_pua_slot() { + let got = parse(b"25a1;c;cp=E0A0").unwrap(); + assert_eq!(got, GlyphCommand::Clear { cp: Some(0xE0A0) }); + } + + #[test] + fn clear_rejects_non_pua_cp() { + assert_eq!(parse(b"25a1;c;cp=61"), Err(ParseError::ClearOutOfNamespace)); + assert_eq!( + parse(b"25a1;c;cp=1F600"), + Err(ParseError::ClearOutOfNamespace) + ); + } + + #[test] + fn clear_rejects_sequence_cp() { + assert!(matches!( + parse(b"25a1;c;cp=E0A0,E0A1"), + Err(ParseError::Malformed(_)) + )); + } + + #[test] + fn clear_all() { + let got = parse(b"25a1;c").unwrap(); + assert_eq!(got, GlyphCommand::Clear { cp: None }); + } + + #[test] + fn parses_support_with_no_params() { + assert_eq!(parse(b"25a1;s").unwrap(), GlyphCommand::Support); + } + + #[test] + fn support_ignores_unknown_params() { + // §11: unknown params are silently ignored. The verb is + // parameter-free, but a forward-compatible client may send + // hints; we still produce a valid reply. + assert_eq!( + parse(b"25a1;s;future=1;anything=else").unwrap(), + GlyphCommand::Support + ); + } + + #[test] + fn support_response_advertises_glyf_colrv0_colrv1() { + // bit 0 = glyf, bit 1 = colrv0, bit 2 = colrv1. + assert_eq!( + format_support_response(SUPPORTED_FORMATS), + "\x1b_25a1;s;fmt=7\x1b\\" + ); + } + + #[test] + fn support_response_encodes_arbitrary_bitfield() { + assert_eq!( + format_support_response(0b0000_0011), + "\x1b_25a1;s;fmt=3\x1b\\" + ); + assert_eq!(format_support_response(0), "\x1b_25a1;s;fmt=0\x1b\\"); + } + + #[test] + fn unknown_verb_is_malformed() { + assert!(matches!( + parse(b"25a1;z;cp=0061"), + Err(ParseError::Malformed(_)) + )); + } + + /// Build a colour-payload container from component byte slices. + /// Lays out exactly as documented on [`ColrContainer`]. + fn build_container(glyphs: &[&[u8]], colr: &[u8], cpal: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(glyphs.len() as u16).to_be_bytes()); + for g in glyphs { + out.extend_from_slice(&(g.len() as u16).to_be_bytes()); + out.extend_from_slice(g); + } + out.extend_from_slice(&(colr.len() as u16).to_be_bytes()); + out.extend_from_slice(colr); + out.extend_from_slice(&(cpal.len() as u16).to_be_bytes()); + out.extend_from_slice(cpal); + out + } + + #[test] + fn parses_colrv0_single_glyph() { + let container = build_container(&[&[0xAA, 0xBB]], &[0x01; 14], &[0x02; 12]); + let body = format!("25a1;r;cp=E0A0;fmt=colrv0;upm=1000;{}", b64(&container)); + let got = parse(body.as_bytes()).unwrap(); + match got { + GlyphCommand::Register { + cp: 0xE0A0, + payload: + GlyphPayload::ColrV0 { + container: c, + upm: 1000, + }, + reply: ReplyMode::All, + } => { + assert_eq!(c.glyphs.len(), 1); + assert_eq!(c.glyphs[0], vec![0xAA, 0xBB]); + assert_eq!(c.colr.len(), 14); + assert_eq!(c.cpal.len(), 12); + } + other => panic!("expected colrv0 register, got {:?}", other), + } + } + + #[test] + fn parses_colrv1_multi_glyph_with_empty_cpal() { + // CPAL can legitimately be zero-length when the COLR uses only + // foreground or direct-sRGB paints (v1 doesn't require CPAL at + // all if no palette index is referenced). + let container = build_container( + &[&[0x01], &[0x02, 0x03], &[0x04, 0x05, 0x06]], + &[0xF0; 32], + &[], + ); + let body = format!("25a1;r;cp=100000;fmt=colrv1;upm=2048;{}", b64(&container)); + let got = parse(body.as_bytes()).unwrap(); + match got { + GlyphCommand::Register { + cp: 0x100000, + payload: + GlyphPayload::ColrV1 { + container: c, + upm: 2048, + }, + reply: ReplyMode::All, + } => { + assert_eq!(c.glyphs.len(), 3); + assert_eq!(c.glyphs[2], vec![0x04, 0x05, 0x06]); + assert_eq!(c.colr.len(), 32); + assert!(c.cpal.is_empty()); + } + other => panic!("expected colrv1 register, got {:?}", other), + } + } + + #[test] + fn colr_rejects_zero_glyphs() { + // Every colour glyph needs at least one outline; `0 glyphs` is + // meaningless and likely indicates a corrupt payload. + let container = build_container(&[], &[0x00; 4], &[]); + let body = format!("25a1;r;cp=E0A0;fmt=colrv0;upm=1000;{}", b64(&container)); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn colr_rejects_empty_colr_table() { + let container = build_container(&[&[0x01]], &[], &[]); + let body = format!("25a1;r;cp=E0A0;fmt=colrv0;upm=1000;{}", b64(&container)); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn colr_rejects_truncated_payload() { + // Claim 2 glyphs but only ship one — the cursor runs out of + // bytes inside the loop. + let mut bad = Vec::new(); + bad.extend_from_slice(&2u16.to_be_bytes()); + bad.extend_from_slice(&1u16.to_be_bytes()); + bad.push(0xAA); + // …no second glyph, no COLR, no CPAL. + let body = format!("25a1;r;cp=E0A0;fmt=colrv1;upm=1000;{}", b64(&bad)); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn colr_rejects_trailing_garbage() { + // Extra bytes after the CPAL slice means the sender's layout + // doesn't match ours; reject rather than silently ignoring. + let mut container = build_container(&[&[0x01]], &[0x00; 4], &[]); + container.push(0xFF); + let body = format!("25a1;r;cp=E0A0;fmt=colrv0;upm=1000;{}", b64(&container)); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn colr_rejects_excessive_glyph_count() { + // n_glyphs = MAX_COLR_GLYPHS + 1 blows the bound. + let mut bad = Vec::new(); + bad.extend_from_slice(&(MAX_COLR_GLYPHS + 1).to_be_bytes()); + // … no actual glyph bytes; parse should reject at the count. + let body = format!("25a1;r;cp=E0A0;fmt=colrv0;upm=1000;{}", b64(&bad)); + assert!(matches!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + reason: RegisterError::MalformedPayload, + .. + }) + )); + } + + #[test] + fn register_defaults_reply_to_all() { + let payload = b64(&[0x01]); + let body = format!("25a1;r;cp=E0A0;upm=1000;{}", payload); + match parse(body.as_bytes()).unwrap() { + GlyphCommand::Register { reply, .. } => { + assert_eq!(reply, ReplyMode::All); + } + other => panic!("expected register, got {:?}", other), + } + } + + #[test] + fn register_accepts_every_reply_level() { + // reply=0 → None, reply=1 → All, reply=2 → ErrorsOnly. + let payload = b64(&[0x01]); + for (raw, expected) in [ + ("0", ReplyMode::None), + ("1", ReplyMode::All), + ("2", ReplyMode::ErrorsOnly), + ] { + let body = format!("25a1;r;cp=E0A0;reply={};upm=1000;{}", raw, payload); + match parse(body.as_bytes()).unwrap() { + GlyphCommand::Register { reply, .. } => { + assert_eq!( + reply, expected, + "reply={} should map to {:?}", + raw, expected + ); + } + other => panic!("expected register, got {:?}", other), + } + } + } + + #[test] + fn register_reply_propagates_on_parse_failure() { + // Non-PUA cp fails validation; the reply level must propagate + // into the error so the dispatcher can honour it consistently. + let payload = b64(&[0x01]); + let body = format!("25a1;r;cp=61;reply=0;upm=1000;{}", payload); + assert_eq!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + cp: 0x61, + reason: RegisterError::OutOfNamespace, + reply: ReplyMode::None, + }) + ); + } + + #[test] + fn register_reply_unknown_values_fall_back_to_all() { + // Per §11 unknown-params rule, garbage values don't break the + // register — they just revert to the default reply behaviour. + let payload = b64(&[0x01]); + for bad in ["3", "true", "yes", "01", ""].iter() { + let body = format!("25a1;r;cp=E0A0;reply={};upm=1000;{}", bad, payload); + match parse(body.as_bytes()).unwrap() { + GlyphCommand::Register { reply, .. } => { + assert_eq!( + reply, + ReplyMode::All, + "reply={:?} should fall back to All", + bad + ); + } + other => panic!("expected register, got {:?}", other), + } + } + } + + #[test] + fn reply_mode_emit_matrix() { + // Sanity-check the two helpers the dispatcher relies on. + assert!(ReplyMode::All.emit_success()); + assert!(ReplyMode::All.emit_error()); + assert!(!ReplyMode::ErrorsOnly.emit_success()); + assert!(ReplyMode::ErrorsOnly.emit_error()); + assert!(!ReplyMode::None.emit_success()); + assert!(!ReplyMode::None.emit_error()); + } + + #[test] + fn colr_register_respects_pua_check_before_fmt_parse() { + // Non-PUA should still be rejected for colour formats, and the + // error should be `out_of_namespace` (not a payload error) so + // the client sees the same contract as fmt=glyf. + let container = build_container(&[&[0x01]], &[0x00; 4], &[]); + let body = format!("25a1;r;cp=61;fmt=colrv0;upm=1000;{}", b64(&container)); + assert_eq!( + parse(body.as_bytes()), + Err(ParseError::RegisterFailed { + cp: 0x61, + reason: RegisterError::OutOfNamespace, + reply: ReplyMode::All, + }) + ); + } + + #[test] + fn query_response_encodes_numeric_status() { + assert_eq!( + format_query_response(0xE0A0, QueryStatus::Free), + "\x1b_25a1;q;cp=e0a0;status=0\x1b\\" + ); + assert_eq!( + format_query_response(0xE0A0, QueryStatus::System), + "\x1b_25a1;q;cp=e0a0;status=1\x1b\\" + ); + assert_eq!( + format_query_response(0xE0A0, QueryStatus::Glossary), + "\x1b_25a1;q;cp=e0a0;status=2\x1b\\" + ); + assert_eq!( + format_query_response(0xE0A0, QueryStatus::Both), + "\x1b_25a1;q;cp=e0a0;status=3\x1b\\" + ); + } + + #[test] + fn register_responses() { + assert_eq!( + format_register_ok(0xE0A0), + "\x1b_25a1;r;cp=e0a0;status=0\x1b\\" + ); + assert_eq!( + format_register_error(0x61, RegisterError::OutOfNamespace), + "\x1b_25a1;r;cp=61;status=1;reason=out_of_namespace\x1b\\" + ); + assert_eq!( + format_register_error(0xE0A0, RegisterError::CompositeUnsupported), + "\x1b_25a1;r;cp=e0a0;status=1;reason=composite_unsupported\x1b\\" + ); + } + + #[test] + fn clear_responses() { + assert_eq!( + format_clear_ok(Some(0xE0A0)), + "\x1b_25a1;c;cp=e0a0;status=0\x1b\\" + ); + assert_eq!(format_clear_ok(None), "\x1b_25a1;c;status=0\x1b\\"); + assert_eq!( + format_clear_error_out_of_namespace(), + "\x1b_25a1;c;status=1;reason=out_of_namespace\x1b\\" + ); + } + + #[test] + fn unknown_params_are_ignored() { + let got = parse(b"25a1;q;cp=E0A0;future=1").unwrap(); + assert_eq!(got, GlyphCommand::Query { cp: 0xE0A0 }); + } +} diff --git a/rio-backend/src/ansi/mod.rs b/rio-backend/src/ansi/mod.rs index 78af9222..edce47f6 100644 --- a/rio-backend/src/ansi/mod.rs +++ b/rio-backend/src/ansi/mod.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; pub mod charset; pub mod control; +pub mod glyph_protocol; pub mod graphics; pub mod iterm2_image_protocol; pub mod kitty_graphics_protocol; diff --git a/rio-backend/src/crosswords/mod.rs b/rio-backend/src/crosswords/mod.rs index 0642d5a4..ea25f4fd 100644 --- a/rio-backend/src/crosswords/mod.rs +++ b/rio-backend/src/crosswords/mod.rs @@ -440,6 +440,15 @@ where pub title: String, damage: TermDamageState, pub graphics: Graphics, + /// Per-session registry of glyphs registered over Glyph Protocol + /// (APC `25a1`). `None` until a program in this session actually + /// uses the protocol, so terminals that never see a Glyph + /// Protocol message pay zero cost — no Arc allocation, no + /// per-frame attach call. Lazily initialised by `glyph_register`. + /// Two tabs can register conflicting glyphs for the same + /// codepoint; each `Crosswords` owns its own registry once + /// initialised. + pub glyph_registry: Option, pub cursor_shape: CursorShape, pub default_cursor_shape: CursorShape, pub blinking_cursor: bool, @@ -498,6 +507,7 @@ impl Crosswords { | Mode::URGENCY_HINTS, damage: TermDamageState::new(rows), graphics: Graphics::new(&dimensions), + glyph_registry: None, default_cursor_shape: cursor_shape, cursor_shape, blinking_cursor: false, @@ -3917,6 +3927,131 @@ impl Handler for Crosswords { .send_event(RioEvent::PtyWrite(self.route_id, response), self.window_id); } + #[inline] + fn glyph_protocol_response(&mut self, response: String) { + self.event_proxy + .send_event(RioEvent::PtyWrite(self.route_id, response), self.window_id); + } + + fn glyph_register( + &mut self, + cp: u32, + payload: crate::ansi::glyph_protocol::GlyphPayload, + ) -> Result<(), crate::ansi::glyph_protocol::RegisterError> { + use crate::ansi::glyph_protocol::{is_pua, GlyphPayload, RegisterError}; + use sugarloaf::font::glyf_decode; + use sugarloaf::font::glyph_registry::{ + GlyphRegistry, RegisterRejection, StoredPayload, + }; + + // PUA check first — callers that bypass the wire parser (direct + // API, tests) still get the rejection without accidentally + // allocating the registry on a doomed request. + if !is_pua(cp) { + return Err(RegisterError::OutOfNamespace); + } + + // Translate a glyf_decode error into the protocol's defined + // `reason=` codes. + fn translate(err: glyf_decode::DecodeError) -> RegisterError { + match err { + glyf_decode::DecodeError::Composite => { + RegisterError::CompositeUnsupported + } + glyf_decode::DecodeError::Hinted => RegisterError::HintingUnsupported, + glyf_decode::DecodeError::Malformed => RegisterError::MalformedPayload, + } + } + + // Validate the monochrome `glyf` payload at register time so a + // bad outline produces a clear error response. For COLR + // containers, validation is render-time only — re-decoding + // every carried outline (up to 1024 per registration) on the + // hot register path costs more than it saves, and the parser + // already catches the common-case malformation. A + // structurally-broken COLR payload manifests as tofu at first + // render rather than a register-time error; that's the + // accepted trade-off. + let (stored, upm) = match payload { + GlyphPayload::Glyf { glyf, upm } => { + glyf_decode::decode(&glyf).map_err(translate)?; + (StoredPayload::Glyf { glyf }, upm) + } + GlyphPayload::ColrV0 { container, upm } => ( + StoredPayload::ColrV0 { + glyphs: container.glyphs, + colr: container.colr, + cpal: container.cpal, + }, + upm, + ), + GlyphPayload::ColrV1 { container, upm } => ( + StoredPayload::ColrV1 { + glyphs: container.glyphs, + colr: container.colr, + cpal: container.cpal, + }, + upm, + ), + }; + + // Lazily allocate the registry — idle terminals that never see + // Glyph Protocol traffic stay at `None` and pay nothing per + // frame. The first allocation fires `GlyphProtocolInstalled` + // so the frontend can wire the registry into the font library + // exactly once per session. + let was_uninitialised = self.glyph_registry.is_none(); + let registry = self.glyph_registry.get_or_insert_with(GlyphRegistry::new); + + let result = match registry.register(cp, stored, upm) { + Ok(_evicted) => Ok(()), + Err(RegisterRejection::OutOfNamespace) => { + // Unreachable given the is_pua check above, but the + // registry double-checks so the defence exists. + Err(RegisterError::OutOfNamespace) + } + }; + + if was_uninitialised && result.is_ok() { + let registry = registry.clone(); + self.event_proxy.send_event( + RioEvent::GlyphProtocolInstalled { + route_id: self.route_id, + registry, + }, + self.window_id, + ); + } + + result + } + + fn glyph_clear(&mut self, cp: Option) { + // Nothing to clear if nothing was ever registered. + let Some(registry) = self.glyph_registry.as_ref() else { + return; + }; + match cp { + None => registry.clear_all(), + Some(cp) => registry.clear_one(cp), + } + } + + fn glyph_query(&mut self, cp: u32) { + // Defer to the frontend: only it has access to both the per- + // route registry and the FontLibrary, so only it can compute + // the System / Glossary / Both bits accurately. The frontend + // formats the reply and writes it back to this pane's PTY + // asynchronously. + self.event_proxy.send_event( + RioEvent::GlyphProtocolQuery { + route_id: self.route_id, + cp, + }, + self.window_id, + ); + } + #[inline] fn kitty_chunking_state_mut( &mut self, @@ -4235,6 +4370,148 @@ mod tests { use crate::crosswords::CrosswordsSize; use crate::event::VoidListener; + fn make_crosswords() -> Crosswords { + let size = CrosswordsSize::new(4, 4); + let window_id = crate::event::WindowId::from(0); + Crosswords::new(size, CursorShape::Block, VoidListener {}, window_id, 0, 10) + } + + // Minimum-valid simple glyph: one contour, one on-curve point. + fn minimal_glyf_bytes() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); // numberOfContours + v.extend_from_slice(&[0u8; 8]); // bounding box + v.extend_from_slice(&0u16.to_be_bytes()); // endPtsOfContours[0] + v.extend_from_slice(&0u16.to_be_bytes()); // instructionLength + v.push(0x01); // flags: on-curve, no shorts + v.extend_from_slice(&0i16.to_be_bytes()); // x delta + v.extend_from_slice(&0i16.to_be_bytes()); // y delta + v + } + + fn glyf_payload( + bytes: Vec, + upm: u16, + ) -> crate::ansi::glyph_protocol::GlyphPayload { + crate::ansi::glyph_protocol::GlyphPayload::Glyf { glyf: bytes, upm } + } + + fn registry_contains(cw: &Crosswords, cp: u32) -> bool { + cw.glyph_registry.as_ref().is_some_and(|r| r.contains(cp)) + } + + fn registry_len(cw: &Crosswords) -> usize { + cw.glyph_registry.as_ref().map_or(0, |r| r.len()) + } + + #[test] + fn glyph_registry_is_none_until_first_register() { + let cw = make_crosswords(); + assert!(cw.glyph_registry.is_none()); + } + + #[test] + fn glyph_protocol_register_populates_registry() { + let mut cw = make_crosswords(); + + let glyf = minimal_glyf_bytes(); + // E0A0 is the Powerline branch codepoint — in basic PUA. + let res = Handler::glyph_register(&mut cw, 0xE0A0, glyf_payload(glyf, 1000)); + assert!(res.is_ok()); + assert!(cw.glyph_registry.is_some()); + assert!(registry_contains(&cw, 0xE0A0)); + } + + #[test] + fn glyph_protocol_register_rejects_non_pua() { + use crate::ansi::glyph_protocol::RegisterError; + let mut cw = make_crosswords(); + + // 0x61 is 'a' — not in PUA. Registry must refuse. + let res = Handler::glyph_register( + &mut cw, + 0x61, + glyf_payload(minimal_glyf_bytes(), 1000), + ); + assert_eq!(res, Err(RegisterError::OutOfNamespace)); + assert!(cw.glyph_registry.is_none()); + } + + #[test] + fn glyph_protocol_register_rejects_hinted_payload() { + use crate::ansi::glyph_protocol::RegisterError; + let mut cw = make_crosswords(); + + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); + v.extend_from_slice(&[0u8; 8]); + v.extend_from_slice(&0u16.to_be_bytes()); // endPts[0] + v.extend_from_slice(&1u16.to_be_bytes()); // instructionLength = 1 + v.push(0x00); // the instruction + v.push(0x01); // on-curve flag + v.extend_from_slice(&0i16.to_be_bytes()); + v.extend_from_slice(&0i16.to_be_bytes()); + + let res = Handler::glyph_register(&mut cw, 0xE0A0, glyf_payload(v, 1000)); + assert_eq!(res, Err(RegisterError::HintingUnsupported)); + // Decode failed before the registry was touched, so it stays + // uninitialised. + assert!(cw.glyph_registry.is_none()); + } + + #[test] + fn glyph_protocol_clear_before_any_register_is_noop() { + let mut cw = make_crosswords(); + Handler::glyph_clear(&mut cw, None); + // No panic, registry still absent. + assert!(cw.glyph_registry.is_none()); + } + + #[test] + fn glyph_protocol_clear_all_wipes_registry() { + let mut cw = make_crosswords(); + Handler::glyph_register( + &mut cw, + 0xE0A0, + glyf_payload(minimal_glyf_bytes(), 1000), + ) + .unwrap(); + Handler::glyph_register( + &mut cw, + 0xE0A1, + glyf_payload(minimal_glyf_bytes(), 1000), + ) + .unwrap(); + assert_eq!(registry_len(&cw), 2); + + Handler::glyph_clear(&mut cw, None); + // Clear-all empties the registry but leaves the Arc in place, + // since a program that cleared once will likely register again. + assert_eq!(registry_len(&cw), 0); + assert!(cw.glyph_registry.is_some()); + } + + #[test] + fn glyph_protocol_clear_one_leaves_others_intact() { + let mut cw = make_crosswords(); + Handler::glyph_register( + &mut cw, + 0xE0A0, + glyf_payload(minimal_glyf_bytes(), 1000), + ) + .unwrap(); + Handler::glyph_register( + &mut cw, + 0xE0A1, + glyf_payload(minimal_glyf_bytes(), 1000), + ) + .unwrap(); + + Handler::glyph_clear(&mut cw, Some(0xE0A0)); + assert!(!registry_contains(&cw, 0xE0A0)); + assert!(registry_contains(&cw, 0xE0A1)); + } + #[test] fn scroll_up() { let size = CrosswordsSize::new(1, 10); diff --git a/rio-backend/src/event/mod.rs b/rio-backend/src/event/mod.rs index 6874fea5..b2693c00 100644 --- a/rio-backend/src/event/mod.rs +++ b/rio-backend/src/event/mod.rs @@ -77,6 +77,28 @@ pub enum RioEvent { route_id: usize, queues: UpdateQueues, }, + /// A pane's Glyph Protocol registry just became live (first + /// `register` after session start, or first register following + /// a clear-all). Frontend installs it into the font library so + /// subsequent renders consult it. Fires at most once per + /// (route_id × registry-arc) pair; the registry is Arc-shared, + /// so further `register`/`clear` mutations made through the + /// existing handle are visible without re-firing. + GlyphProtocolInstalled { + route_id: usize, + registry: sugarloaf::font::glyph_registry::GlyphRegistry, + }, + /// A `q` (query) request arrived from the PTY in `route_id`. The + /// frontend computes the four-state status — System and/or + /// Glossary coverage — by consulting both `FontLibrary` (system + /// fonts) and the per-route glyph registry, then writes the + /// formatted reply back to the same pane's PTY. Asynchronous + /// because the dispatcher (in rio-backend) doesn't have access + /// to the FontLibrary; the frontend does. + GlyphProtocolQuery { + route_id: usize, + cp: u32, + }, Paste, Copy(String), UpdateFontSize(u8), @@ -238,6 +260,12 @@ impl Debug for RioEvent { RioEvent::TerminalDamaged(route_id) => { write!(f, "TerminalDamaged route {route_id}") } + RioEvent::GlyphProtocolInstalled { route_id, .. } => { + write!(f, "GlyphProtocolInstalled route {route_id}") + } + RioEvent::GlyphProtocolQuery { route_id, cp } => { + write!(f, "GlyphProtocolQuery route {route_id} cp {cp:#x}") + } RioEvent::Scroll(scroll) => write!(f, "Scroll {scroll:?}"), RioEvent::Bell => write!(f, "Bell"), RioEvent::DesktopNotification { title, body } => { diff --git a/rio-backend/src/performer/handler.rs b/rio-backend/src/performer/handler.rs index 35d5f6a1..49d31366 100644 --- a/rio-backend/src/performer/handler.rs +++ b/rio-backend/src/performer/handler.rs @@ -1,3 +1,4 @@ +use crate::ansi::glyph_protocol; use crate::ansi::iterm2_image_protocol; use crate::ansi::kitty_graphics_protocol; use crate::ansi::CursorShape; @@ -458,6 +459,35 @@ pub trait Handler { /// Send a kitty graphics protocol response fn kitty_graphics_response(&mut self, _response: String) {} + /// Send a Glyph Protocol response (query reply, register ack, etc.). + fn glyph_protocol_response(&mut self, _response: String) {} + + /// Register a custom glyph at a client-chosen PUA codepoint. The + /// parser has already verified `cp` is in PUA and the container + /// size is within bounds. The `payload` carries format-specific + /// data: monochrome `glyf`, or a `colrv0`/`colrv1` colour + /// container. `Err(reason)` causes the dispatcher to emit an error + /// response. + fn glyph_register( + &mut self, + _cp: u32, + _payload: glyph_protocol::GlyphPayload, + ) -> Result<(), glyph_protocol::RegisterError> { + Ok(()) + } + + /// Clear one registration (`Some(cp)`) or every registration in + /// the session (`None`). + fn glyph_clear(&mut self, _cp: Option) {} + + /// Forward a `q` (query) request to the frontend so it can + /// classify the codepoint as `Free` / `System` / `Glossary` / + /// `Both` (spec §5.2). Asynchronous because the System / Both + /// bits require FontLibrary access that lives outside the + /// terminal-backend layer; the frontend formats the reply and + /// writes it back to the originating pane's PTY directly. + fn glyph_query(&mut self, _cp: u32) {} + /// Get mutable access to the kitty graphics chunking state /// Used by the APC handler to accumulate chunked image transmissions /// @@ -775,6 +805,19 @@ impl<'a, H: Handler + 'a, T: Timeout> Performer<'a, H, T> { String::from_utf8_lossy(&data[..data.len().min(50)]) ); + // Check if this is a Glyph Protocol APC (starts with "25a1"). + // Glyph Protocol is checked before Kitty so its fixed-string + // prefix short-circuits quickly; the two prefixes are disjoint. + if data.starts_with(glyph_protocol::GLYPH_PROTOCOL_PREFIX) { + // Copy the body off the shared apc_state buffer so that + // dispatch_glyph_protocol can take `&mut self` (the handler + // trait methods need it). Glyph Protocol payloads are + // bounded by MAX_PAYLOAD_BYTES, so this is cheap. + let body = data.to_vec(); + self.dispatch_glyph_protocol(&body); + return; + } + // Check if this is a Kitty graphics protocol APC (starts with 'G') if data.first() == Some(&b'G') { debug!("[process_apc_buffer] Kitty graphics APC detected"); @@ -877,6 +920,66 @@ impl<'a, H: Handler + 'a, T: Timeout> Performer<'a, H, T> { ); } } + + /// Parse and dispatch a Glyph Protocol APC. `data` starts with the + /// `25a1` identifier and excludes the APC introducer / terminator. + fn dispatch_glyph_protocol(&mut self, data: &[u8]) { + match glyph_protocol::parse(data) { + Ok(glyph_protocol::GlyphCommand::Support) => { + let resp = glyph_protocol::format_support_response( + glyph_protocol::SUPPORTED_FORMATS, + ); + self.handler.glyph_protocol_response(resp); + } + Ok(glyph_protocol::GlyphCommand::Query { cp }) => { + // Fire-and-forget: the handler emits a frontend-bound + // event with `route_id + cp`. The frontend computes + // System / Glossary coverage and writes the formatted + // reply back to the originating pane's PTY directly. + self.handler.glyph_query(cp); + } + Ok(glyph_protocol::GlyphCommand::Register { cp, payload, reply }) => { + match self.handler.glyph_register(cp, payload) { + Ok(()) => { + if reply.emit_success() { + let resp = glyph_protocol::format_register_ok(cp); + self.handler.glyph_protocol_response(resp); + } + } + Err(reason) => { + if reply.emit_error() { + let resp = glyph_protocol::format_register_error(cp, reason); + self.handler.glyph_protocol_response(resp); + } + } + } + } + Ok(glyph_protocol::GlyphCommand::Clear { cp }) => { + self.handler.glyph_clear(cp); + let resp = glyph_protocol::format_clear_ok(cp); + self.handler.glyph_protocol_response(resp); + } + Err(glyph_protocol::ParseError::NotGlyphProtocol) => { + // Shouldn't happen — the prefix check in process_apc_buffer + // already confirmed the identifier. Fall through silently + // rather than warn, since a future dispatcher reorder + // could make this reachable. + } + Err(glyph_protocol::ParseError::RegisterFailed { cp, reason, reply }) => { + if reply.emit_error() { + let resp = glyph_protocol::format_register_error(cp, reason); + self.handler.glyph_protocol_response(resp); + } + } + Err(glyph_protocol::ParseError::ClearOutOfNamespace) => { + let resp = glyph_protocol::format_clear_error_out_of_namespace(); + self.handler.glyph_protocol_response(resp); + } + Err(glyph_protocol::ParseError::Malformed(why)) => { + warn!("[glyph_protocol] malformed APC: {why}"); + } + } + } } impl copa::Perform for Performer<'_, U, T> { diff --git a/sugarloaf/src/font/glyf_decode.rs b/sugarloaf/src/font/glyf_decode.rs new file mode 100644 index 00000000..282c60e6 --- /dev/null +++ b/sugarloaf/src/font/glyf_decode.rs @@ -0,0 +1,435 @@ +// OpenType `glyf` simple-glyph decoder. +// +// Parses a bare TrueType simple-glyph record (as sent over the Glyph +// Protocol wire) into a neutral [`Outline`], then walks the outline to +// produce a sequence of [`PathCmd`]s suitable for feeding to any vector +// path library (zeno, lyon, skia, …). +// +// The low-level byte parsing is delegated to `read-fonts`, which backs +// `skrifa`. That keeps us on the same battle-tested parser skrifa itself +// uses for rendering real fonts. The contour walker — the part that +// turns on-curve/off-curve sequences into move/line/quad commands +// following the TrueType quadratic-Bézier rules — is local because +// skrifa's equivalent (`contour_to_path`) is `pub(crate)`. +// +// References: +// - OpenType glyf: https://learn.microsoft.com/en-us/typography/opentype/spec/glyf +// - Apple TrueType Reference Manual Chapter 6 + +// `skrifa::raw` is `pub extern crate read_fonts`, so this routes through +// the version skrifa already pulls in instead of declaring a duplicate +// direct dep. +use skrifa::raw::tables::glyf::{CurvePoint, SimpleGlyph}; +use skrifa::raw::{FontData, FontRead}; + +/// Reasons a `glyf` record can be rejected. Maps onto Glyph Protocol v1 +/// `reason=` error codes where applicable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecodeError { + /// numberOfContours < 0 (composite glyph reference). Our subset does + /// not support composites. + Composite, + /// instructionLength > 0. Hinting bytecode is not accepted. + Hinted, + /// Payload ended before the decoder expected, or a structural + /// invariant was violated. + Malformed, +} + +/// A single decoded point. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Point { + pub x: i32, + pub y: i32, + pub on_curve: bool, +} + +/// Fully-decoded simple glyph: a list of closed contours, each a +/// `Vec`, plus the glyph's bounding box in its authoring +/// coordinate space. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outline { + pub contours: Vec>, + pub x_min: i32, + pub y_min: i32, + pub x_max: i32, + pub y_max: i32, +} + +/// Decode a simple-glyph record. +pub fn decode(data: &[u8]) -> Result { + // Peek at numberOfContours before handing to read-fonts, because + // `SimpleGlyph::read` will happily parse a composite record as a + // simple one and give back garbage. The first two bytes of every + // glyph record are the signed 16-bit numberOfContours. + if data.len() < 10 { + return Err(DecodeError::Malformed); + } + let num_contours = i16::from_be_bytes([data[0], data[1]]); + if num_contours < 0 { + return Err(DecodeError::Composite); + } + + let glyph = + SimpleGlyph::read(FontData::new(data)).map_err(|_| DecodeError::Malformed)?; + + if glyph.instruction_length() != 0 { + return Err(DecodeError::Hinted); + } + + let x_min = glyph.x_min() as i32; + let y_min = glyph.y_min() as i32; + let x_max = glyph.x_max() as i32; + let y_max = glyph.y_max() as i32; + + let end_pts: Vec = glyph + .end_pts_of_contours() + .iter() + .map(|v| v.get() as usize) + .collect(); + if end_pts.is_empty() { + return Ok(Outline { + contours: Vec::new(), + x_min, + y_min, + x_max, + y_max, + }); + } + // End-points must be strictly increasing. + for w in end_pts.windows(2) { + if w[1] <= w[0] { + return Err(DecodeError::Malformed); + } + } + let num_points = end_pts[end_pts.len() - 1] + 1; + + let pts: Vec = glyph.points().collect(); + if pts.len() != num_points { + return Err(DecodeError::Malformed); + } + + // Split the flat point list into contours. + let mut contours = Vec::with_capacity(end_pts.len()); + let mut start = 0usize; + for &end in &end_pts { + let mut contour = Vec::with_capacity(end - start + 1); + for p in &pts[start..=end] { + contour.push(Point { + x: p.x as i32, + y: p.y as i32, + on_curve: p.on_curve, + }); + } + contours.push(contour); + start = end + 1; + } + + Ok(Outline { + contours, + x_min, + y_min, + x_max, + y_max, + }) +} + +/// A single path command produced by [`Outline::walk`]. Intentionally +/// does not depend on any external path library so the walker can be +/// tested in isolation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PathCmd { + MoveTo { x: f32, y: f32 }, + LineTo { x: f32, y: f32 }, + QuadTo { cx: f32, cy: f32, x: f32, y: f32 }, + Close, +} + +impl Outline { + /// Walk each contour and emit path commands following the TrueType + /// quadratic-Bézier rules. `upm` is the authoring coordinate space; + /// `pixel_size` is the target render size. Coordinates are scaled + /// and Y-flipped so the rasterizer (which expects Y-down) sees the + /// glyph the right way up. + pub fn walk(&self, upm: u16, pixel_size: f32) -> Vec { + if self.contours.is_empty() || upm == 0 { + return Vec::new(); + } + let scale = pixel_size / upm as f32; + let fx = |x: i32| x as f32 * scale; + // glyf Y is up, most rasterizers want Y-down. Subtract from + // y_max * scale so the glyph sits at (0,0) of the destination. + let y_base = self.y_max as f32 * scale; + let fy = |y: i32| y_base - y as f32 * scale; + + let mut out = Vec::new(); + for contour in &self.contours { + walk_contour(contour, &fx, &fy, &mut out); + } + out + } +} + +fn walk_contour( + pts: &[Point], + fx: &impl Fn(i32) -> f32, + fy: &impl Fn(i32) -> f32, + out: &mut Vec, +) { + if pts.is_empty() { + return; + } + let n = pts.len(); + + // TrueType allows a contour to start with an off-curve point. When + // that happens, the starting on-curve point is synthesised: if the + // last point is on-curve, use it; otherwise use the midpoint + // between the first and last off-curve points. + // + // `steps` is the number of points we'll iterate after emitting + // MoveTo: one fewer than `n` when the start is a real point + // (otherwise we'd revisit it via the wrap), the full `n` when the + // start is a synthesised midpoint. + let (start_x, start_y, start_idx, steps) = if pts[0].on_curve { + (fx(pts[0].x), fy(pts[0].y), 1usize, n - 1) + } else if pts[n - 1].on_curve { + (fx(pts[n - 1].x), fy(pts[n - 1].y), 0usize, n - 1) + } else { + // Linear scale + flip is an affine transform, so the rendered + // midpoint equals the midpoint of the rendered endpoints. + let mx = (fx(pts[0].x) + fx(pts[n - 1].x)) / 2.0; + let my = (fy(pts[0].y) + fy(pts[n - 1].y)) / 2.0; + (mx, my, 0usize, n) + }; + + out.push(PathCmd::MoveTo { + x: start_x, + y: start_y, + }); + + let mut i = start_idx; + let mut pending_off: Option<(f32, f32)> = None; + let mut visited = 0; + while visited < steps { + let p = pts[i]; + let px = fx(p.x); + let py = fy(p.y); + if p.on_curve { + match pending_off.take() { + Some((cx, cy)) => out.push(PathCmd::QuadTo { + cx, + cy, + x: px, + y: py, + }), + None => out.push(PathCmd::LineTo { x: px, y: py }), + } + } else { + match pending_off.take() { + Some((cx, cy)) => { + // Two off-curves in a row: emit a quad to their + // implied on-curve midpoint, then carry the new + // off-curve as the next control. + let mx = (cx + px) / 2.0; + let my = (cy + py) / 2.0; + out.push(PathCmd::QuadTo { + cx, + cy, + x: mx, + y: my, + }); + pending_off = Some((px, py)); + } + None => { + pending_off = Some((px, py)); + } + } + } + i = (i + 1) % n; + visited += 1; + } + + // If a control point is still pending at the end of the contour, + // close the final curve back to the start with a quad. + if let Some((cx, cy)) = pending_off.take() { + out.push(PathCmd::QuadTo { + cx, + cy, + x: start_x, + y: start_y, + }); + } + + out.push(PathCmd::Close); +} + +#[cfg(test)] +mod tests { + use super::*; + + // Flag bits, duplicated here only so the tests can construct + // records without reaching into read-fonts internals. + const FLAG_ON_CURVE: u8 = 0x01; + const FLAG_REPEAT: u8 = 0x08; + + /// Hand-encode a triangle so the test doesn't depend on fontTools. + fn triangle_bytes() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); // numberOfContours + v.extend_from_slice(&100i16.to_be_bytes()); // xMin + v.extend_from_slice(&100i16.to_be_bytes()); // yMin + v.extend_from_slice(&900i16.to_be_bytes()); // xMax + v.extend_from_slice(&900i16.to_be_bytes()); // yMax + v.extend_from_slice(&2u16.to_be_bytes()); // endPtsOfContours + v.extend_from_slice(&0u16.to_be_bytes()); // instructionLength + v.push(FLAG_ON_CURVE); + v.push(FLAG_ON_CURVE); + v.push(FLAG_ON_CURVE); + // X deltas: 500, -400, 800 (signed 16-bit each). + v.extend_from_slice(&500i16.to_be_bytes()); + v.extend_from_slice(&(-400i16).to_be_bytes()); + v.extend_from_slice(&800i16.to_be_bytes()); + // Y deltas: 900, -800, 0. + v.extend_from_slice(&900i16.to_be_bytes()); + v.extend_from_slice(&(-800i16).to_be_bytes()); + v.extend_from_slice(&0i16.to_be_bytes()); + v + } + + #[test] + fn decodes_triangle() { + let out = decode(&triangle_bytes()).unwrap(); + assert_eq!(out.contours.len(), 1); + let c = &out.contours[0]; + assert_eq!(c.len(), 3); + assert_eq!( + c[0], + Point { + x: 500, + y: 900, + on_curve: true + } + ); + assert_eq!( + c[1], + Point { + x: 100, + y: 100, + on_curve: true + } + ); + assert_eq!( + c[2], + Point { + x: 900, + y: 100, + on_curve: true + } + ); + } + + #[test] + fn rejects_composite() { + let mut v = Vec::new(); + v.extend_from_slice(&(-1i16).to_be_bytes()); + v.extend_from_slice(&[0u8; 8]); + assert_eq!(decode(&v), Err(DecodeError::Composite)); + } + + #[test] + fn rejects_hinting() { + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); // numberOfContours + v.extend_from_slice(&[0u8; 8]); // bounding box + v.extend_from_slice(&0u16.to_be_bytes()); // endPtsOfContours[0] = 0 + v.extend_from_slice(&1u16.to_be_bytes()); // instructionLength = 1 + v.push(0x00); // one instruction byte — enough to trip the check + v.push(FLAG_ON_CURVE); // single on-curve point + v.extend_from_slice(&0i16.to_be_bytes()); // x delta + v.extend_from_slice(&0i16.to_be_bytes()); // y delta + assert_eq!(decode(&v), Err(DecodeError::Hinted)); + } + + #[test] + fn rejects_truncated() { + assert_eq!(decode(&[]), Err(DecodeError::Malformed)); + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); + v.extend_from_slice(&[0u8; 8]); + // No contour data at all — header only. + assert_eq!(decode(&v), Err(DecodeError::Malformed)); + } + + #[test] + fn handles_repeat_flag() { + // 4 points with identical flags, encoded via REPEAT. + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); + v.extend_from_slice(&[0u8; 8]); + v.extend_from_slice(&3u16.to_be_bytes()); // endPtsOfContours + v.extend_from_slice(&0u16.to_be_bytes()); // instructionLength + v.push(FLAG_ON_CURVE | FLAG_REPEAT); + v.push(3); // repeat this flag 3 more times → 4 points total + for dx in [10i16, 10, 10, 10] { + v.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 10, 0, -10] { + v.extend_from_slice(&dy.to_be_bytes()); + } + let out = decode(&v).unwrap(); + assert_eq!(out.contours[0].len(), 4); + assert_eq!(out.contours[0][3].x, 40); + assert_eq!(out.contours[0][3].y, 0); + } + + #[test] + fn walk_triangle_produces_move_line_line_close() { + let out = decode(&triangle_bytes()).unwrap(); + let cmds = out.walk(1000, 100.0); + assert!(matches!(cmds[0], PathCmd::MoveTo { .. })); + assert_eq!(cmds.len(), 4); + assert!(matches!(cmds[1], PathCmd::LineTo { .. })); + assert!(matches!(cmds[2], PathCmd::LineTo { .. })); + assert!(matches!(cmds[3], PathCmd::Close)); + } + + #[test] + fn walk_handles_two_off_curves_in_a_row() { + // 4-point contour: on, off, off, on. Adjacent off-curves imply + // an on-curve at their midpoint. + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); + v.extend_from_slice(&[0u8; 8]); + v.extend_from_slice(&3u16.to_be_bytes()); + v.extend_from_slice(&0u16.to_be_bytes()); + v.push(FLAG_ON_CURVE); + v.push(0); + v.push(0); + v.push(FLAG_ON_CURVE); + for dx in [0i16, 100, 100, 0] { + v.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 100, -100, -100] { + v.extend_from_slice(&dy.to_be_bytes()); + } + let out = decode(&v).unwrap(); + let cmds = out.walk(1000, 1000.0); + assert!(cmds.iter().any(|c| matches!(c, PathCmd::QuadTo { .. }))); + assert!(matches!(cmds[0], PathCmd::MoveTo { .. })); + assert!(matches!(cmds.last().unwrap(), PathCmd::Close)); + } + + #[test] + fn scale_and_flip_are_correct() { + let out = decode(&triangle_bytes()).unwrap(); + let cmds = out.walk(1000, 100.0); + // First on-curve is (500,900) in glyf space. Scaled: 500 * 0.1 = 50, + // y_base = 900 * 0.1 = 90, flipped y = 90 - 90 = 0. + if let PathCmd::MoveTo { x, y } = cmds[0] { + assert!((x - 50.0).abs() < 1e-3); + assert!((y - 0.0).abs() < 1e-3); + } else { + panic!("expected MoveTo"); + } + } +} diff --git a/sugarloaf/src/font/glyph_registry.rs b/sugarloaf/src/font/glyph_registry.rs new file mode 100644 index 00000000..7e1f1cee --- /dev/null +++ b/sugarloaf/src/font/glyph_registry.rs @@ -0,0 +1,578 @@ +// Per-terminal registry of glyphs registered over Glyph Protocol. +// +// Each Crosswords (terminal tab) owns one `GlyphRegistry` behind an +// `Arc>`. The rendering pipeline borrows it read-only when +// resolving codepoints to glyph outlines, so concurrent registrations +// from the app side never block a frame. +// +// Two tabs can register conflicting glyphs for the same codepoint — +// each tab sees only its own registry. Registrations live for the +// lifetime of the terminal session and are dropped on close. +// +// The registry holds at most 1024 simultaneous entries. On the 1025th +// register, the oldest entry is evicted (FIFO) to make room. Cleared +// codepoints are removed immediately; re-registering a codepoint +// overwrites the previous entry without affecting FIFO order of +// other entries. Each registration costs one slot regardless of +// payload type — a `ColrV0`/`ColrV1` container with 200 inner +// outlines still occupies a single glossary slot. The `n_glyphs` +// cap inside a COLR payload (see `rio_backend::ansi::glyph_protocol:: +// MAX_COLR_GLYPHS`) is a separate per-payload limit. + +use parking_lot::RwLock; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +/// Sentinel `font_id` returned by `FontLibraryData::find_best_font_match` +/// when the codepoint has a live registration. The rasterizer branches +/// on this value to skip the charmap/shape path and render from the +/// registry instead. +pub const CUSTOM_GLYPH_FONT_ID: usize = usize::MAX; + +/// Same sentinel typed for the grid renderer's u32 atlas key. Equal to +/// `CUSTOM_GLYPH_FONT_ID as u32` on every supported target; we expose +/// it as its own const so call sites stay free of `as` casts. +pub const CUSTOM_GLYPH_FONT_ID_U32: u32 = u32::MAX; + +/// Pack a `(codepoint, version)` pair into the 32-bit `glyph_id` field +/// the grid atlas uses. Each register/clear bumps the registration's +/// `version`, so re-registering the same codepoint produces a fresh +/// atlas key and never serves stale rasterisation. PUA codepoints fit +/// in 21 bits; the remaining 11 bits give 2048 versions before +/// wraparound — large enough that an unrelated collision is +/// astronomical for any realistic register/clear cadence. +#[inline] +pub fn pack_atlas_glyph_id(codepoint: u32, version: u32) -> u32 { + ((version & 0x7FF) << 21) | (codepoint & 0x1F_FFFF) +} + +/// Maximum simultaneous registrations per session (spec §4). Each +/// registration is one slot regardless of payload type. +pub const GLOSSARY_CAPACITY: usize = 1024; + +/// Is `cp` in any of the three Unicode Private Use Areas? This is the +/// check enforced by the `r` verb's parser; mirrored here so the +/// registry itself never stores a non-PUA codepoint even if a future +/// caller forgets to validate. +#[inline] +pub fn is_pua(cp: u32) -> bool { + (0xE000..=0xF8FF).contains(&cp) + || (0xF_0000..=0xF_FFFD).contains(&cp) + || (0x10_0000..=0x10_FFFD).contains(&cp) +} + +/// Payload retained per registration. `Glyf` is a single monochrome +/// outline; `ColrV0` and `ColrV1` carry the full colour container (a +/// table of outlines + raw OpenType `COLR`/`CPAL` bytes) so the +/// renderer can walk the paint graph at any cell size without +/// re-transmitting. The two colour variants share structure; the +/// variant tag tells the renderer which COLR table version to parse. +#[derive(Debug, Clone)] +pub enum StoredPayload { + Glyf { + glyf: Vec, + }, + ColrV0 { + glyphs: Vec>, + colr: Vec, + cpal: Vec, + }, + ColrV1 { + glyphs: Vec>, + colr: Vec, + cpal: Vec, + }, +} + +impl StoredPayload { + /// Constructor for the legacy monochrome-only path. Lets existing + /// tests and call-sites keep their prior ergonomics while the new + /// colour variants are plumbed through. + pub fn glyf(bytes: Vec) -> Self { + StoredPayload::Glyf { glyf: bytes } + } +} + +/// A single registered glyph. The raw payload is retained so the +/// renderer can re-rasterize at any cell size without re-transmitting. +/// +/// `payload` is held behind an `Arc` so [`GlyphRegistry::get`] — and +/// every render-path lookup that goes through it — clones a refcount +/// bump rather than the underlying bytes. A single `ColrV1` payload +/// can carry up to 1024 inner outlines plus the COLR/CPAL tables +/// (megabytes worst-case); without this indirection every atlas miss +/// for that codepoint at a new size bucket would memcpy the lot. +#[derive(Debug, Clone)] +pub struct RegisteredGlyph { + pub payload: Arc, + pub upm: u16, + /// Stable render-side index in `0..GLOSSARY_CAPACITY`. The + /// renderer's glyph-id field is u16, so the slot id fits directly. + /// Indices are reused after eviction or explicit clear, so the + /// atlas cache must be invalidated for a (slot_id, *) pair + /// whenever that happens. + pub index: u16, + /// Per-registration insertion id used to order entries for FIFO + /// eviction. A larger id means "registered later." + pub insertion_id: u64, + /// Bumps on every register call (fresh OR overwrite). The atlas + /// key for a custom glyph is `(CUSTOM_FONT_ID, pack(cp, version), + /// size)`, so any mutation produces a fresh slot and prevents a + /// post-clear or post-overwrite render from serving the previous + /// rasterisation. + pub version: u32, +} + +#[derive(Debug)] +struct Inner { + by_cp: FxHashMap, + /// Reverse map: `indexed[i]` holds the codepoint currently using + /// slot index `i`, or `None` if the slot is free. + indexed: [Option; GLOSSARY_CAPACITY], + /// Monotonic counter that stamps each registration so we can + /// evict the oldest in O(n) over `GLOSSARY_CAPACITY` entries. + next_insertion: u64, + /// Monotonic counter stamped onto every registration's `version`. + /// Wraps around (≈4 billion before wrap) — collisions only matter + /// modulo the 11-bit window the atlas key uses, which is checked + /// in `pack_atlas_glyph_id`. + next_version: u32, +} + +impl Default for Inner { + fn default() -> Self { + Self { + by_cp: FxHashMap::default(), + indexed: [None; GLOSSARY_CAPACITY], + next_insertion: 0, + next_version: 0, + } + } +} + +/// The public registry handle. Cloned cheaply via `Arc`. +#[derive(Debug, Clone, Default)] +pub struct GlyphRegistry { + inner: Arc>, +} + +/// Reasons a register call is rejected by the registry itself (as +/// opposed to parse-time rejections the dispatcher handles). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegisterRejection { + /// `cp` is not in any PUA range. Callers should catch this at the + /// parser; this is a defence-in-depth check. + OutOfNamespace, +} + +impl GlyphRegistry { + pub fn new() -> Self { + Self::default() + } + + /// `true` if `self` and `other` share the same underlying `Arc`. + /// Used by [`FontLibrary::attach_glyph_registry`] to skip the + /// write lock when the same registry is being re-attached on + /// every frame. + #[inline] + pub fn ptr_eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } + + /// Register a glyph at a PUA codepoint. + /// + /// If the codepoint is already registered, the outline is replaced + /// and the existing insertion order and slot index are preserved. + /// + /// If the glossary is full (`GLOSSARY_CAPACITY` entries) and `cp` is NOT already + /// registered, the oldest entry is evicted to make room. Returns + /// `Some(evicted_cp)` in that case so the caller can invalidate + /// its render cache for that codepoint. + pub fn register( + &self, + cp: u32, + payload: StoredPayload, + upm: u16, + ) -> Result, RegisterRejection> { + if !is_pua(cp) { + return Err(RegisterRejection::OutOfNamespace); + } + // Wrap once; subsequent `get` clones share this Arc. + let payload = Arc::new(payload); + let mut inner = self.inner.write(); + + // Allocate a fresh `version` regardless of whether this is an + // overwrite or a brand-new registration: any payload change + // must produce a new atlas key so previously-rasterised pixels + // for `cp` stop being served. + let version = inner.next_version; + inner.next_version = inner.next_version.wrapping_add(1); + + // Overwrite path: reuse the existing slot index. Insertion id + // is preserved so overwrite does not refresh eviction order. + if let Some(existing) = inner.by_cp.get_mut(&cp) { + existing.payload = payload; + existing.upm = upm; + existing.version = version; + return Ok(None); + } + + // Fresh insertion. Find a free slot; if none, evict the + // oldest entry and take its slot. + let (slot_index, evicted) = match inner.indexed.iter().position(|s| s.is_none()) { + Some(i) => (i as u16, None), + None => { + let (evict_cp, evict_entry) = inner + .by_cp + .iter() + .min_by_key(|(_, v)| v.insertion_id) + .map(|(cp, v)| (*cp, v.clone())) + .expect("capacity full but by_cp empty"); + let freed_index = evict_entry.index; + inner.by_cp.remove(&evict_cp); + inner.indexed[freed_index as usize] = None; + (freed_index, Some(evict_cp)) + } + }; + + let id = inner.next_insertion; + inner.next_insertion = inner.next_insertion.wrapping_add(1); + inner.indexed[slot_index as usize] = Some(cp); + inner.by_cp.insert( + cp, + RegisteredGlyph { + payload, + upm, + index: slot_index, + insertion_id: id, + version, + }, + ); + Ok(evicted) + } + + /// Clear one codepoint. No-op if nothing was registered. + pub fn clear_one(&self, cp: u32) { + let mut inner = self.inner.write(); + if let Some(entry) = inner.by_cp.remove(&cp) { + inner.indexed[entry.index as usize] = None; + } + } + + /// Drop every registration and free every slot index. + pub fn clear_all(&self) { + let mut inner = self.inner.write(); + inner.by_cp.clear(); + inner.indexed = [None; GLOSSARY_CAPACITY]; + } + + /// Recover the codepoint that a render-side slot index points at. + /// Used by the rasterizer to look up the outline from an atlas key. + pub fn cp_for_index(&self, index: u16) -> Option { + self.inner + .read() + .indexed + .get(index as usize) + .copied() + .flatten() + } + + /// Look up a registration. + pub fn get(&self, cp: u32) -> Option { + self.inner.read().by_cp.get(&cp).cloned() + } + + /// True iff `cp` has a live custom registration. + pub fn contains(&self, cp: u32) -> bool { + self.inner.read().by_cp.contains_key(&cp) + } + + /// Live registration count. Exposed for tests and telemetry. + pub fn len(&self) -> usize { + self.inner.read().by_cp.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pua_check_matches_spec() { + assert!(is_pua(0xE000)); + assert!(is_pua(0xE0A0)); + assert!(is_pua(0xF8FF)); + assert!(is_pua(0xF_0000)); + assert!(is_pua(0x10_0000)); + assert!(!is_pua(0x61)); // 'a' + assert!(!is_pua(0x1F600)); // emoji + } + + #[test] + fn pack_atlas_glyph_id_is_unique_per_codepoint_and_version() { + // Distinct codepoints with the same version → distinct keys. + assert_ne!( + pack_atlas_glyph_id(0xE0A0, 0), + pack_atlas_glyph_id(0xE0A1, 0) + ); + // Distinct versions with the same codepoint → distinct keys + // (this is the property that fixes the stale-atlas bug). + assert_ne!( + pack_atlas_glyph_id(0xE0A0, 0), + pack_atlas_glyph_id(0xE0A0, 1) + ); + // Wrap-around at the 11-bit version window. Documented + // limitation: register/clear/re-register more than 2048 + // times for the same cp would alias. Acceptable for any + // realistic cadence. + assert_eq!( + pack_atlas_glyph_id(0xE0A0, 0), + pack_atlas_glyph_id(0xE0A0, 0x800) + ); + } + + #[test] + fn register_overwrite_bumps_version_for_atlas_busting() { + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![0xAA]), 1000).unwrap(); + let v_first = r.get(0xE0A0).unwrap().version; + + r.register(0xE0A0, glyf(vec![0xBB]), 1000).unwrap(); + let v_second = r.get(0xE0A0).unwrap().version; + + assert_ne!( + v_first, v_second, + "overwrite must produce a new version so atlas re-rasterises" + ); + } + + #[test] + fn clear_then_reregister_yields_new_version() { + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![0xAA]), 1000).unwrap(); + let v_first = r.get(0xE0A0).unwrap().version; + + r.clear_one(0xE0A0); + r.register(0xE0A0, glyf(vec![0xBB]), 1000).unwrap(); + let v_second = r.get(0xE0A0).unwrap().version; + + assert_ne!( + v_first, v_second, + "register-after-clear must produce a new version" + ); + } + + fn glyf(bytes: Vec) -> StoredPayload { + StoredPayload::glyf(bytes) + } + + fn assert_glyf_bytes(p: &StoredPayload, expected: &[u8]) { + match p { + StoredPayload::Glyf { glyf } => assert_eq!(glyf, expected), + other => panic!("expected Glyf, got {:?}", other), + } + } + + #[test] + fn register_and_lookup() { + let r = GlyphRegistry::new(); + assert_eq!(r.register(0xE0A0, glyf(vec![1, 2, 3]), 1000).unwrap(), None); + let g = r.get(0xE0A0).unwrap(); + assert_glyf_bytes(&g.payload, &[1, 2, 3]); + assert_eq!(g.upm, 1000); + assert!(r.contains(0xE0A0)); + assert_eq!(r.len(), 1); + } + + #[test] + fn register_rejects_non_pua() { + let r = GlyphRegistry::new(); + assert_eq!( + r.register(0x61, glyf(vec![1]), 1000), + Err(RegisterRejection::OutOfNamespace) + ); + assert!(r.is_empty()); + } + + #[test] + fn register_overwrites_preserving_insertion_order_and_index() { + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![1]), 1000).unwrap(); + r.register(0xE0A1, glyf(vec![2]), 1000).unwrap(); + let idx_before = r.get(0xE0A0).unwrap().index; + // Overwrite the first entry — index and insertion order stable. + r.register(0xE0A0, glyf(vec![9]), 2048).unwrap(); + let a = r.get(0xE0A0).unwrap(); + let b = r.get(0xE0A1).unwrap(); + assert_glyf_bytes(&a.payload, &[9]); + assert_eq!(a.upm, 2048); + assert_eq!(a.index, idx_before); + assert!(a.insertion_id < b.insertion_id); + } + + #[test] + fn distinct_registrations_get_distinct_indices() { + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![1]), 1000).unwrap(); + r.register(0xE0A1, glyf(vec![2]), 1000).unwrap(); + r.register(0xE0A2, glyf(vec![3]), 1000).unwrap(); + let i0 = r.get(0xE0A0).unwrap().index; + let i1 = r.get(0xE0A1).unwrap().index; + let i2 = r.get(0xE0A2).unwrap().index; + assert_ne!(i0, i1); + assert_ne!(i1, i2); + assert_ne!(i0, i2); + assert_eq!(r.cp_for_index(i0), Some(0xE0A0)); + assert_eq!(r.cp_for_index(i1), Some(0xE0A1)); + } + + #[test] + fn cleared_slot_is_reused_by_next_registration() { + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![1]), 1000).unwrap(); + let old_index = r.get(0xE0A0).unwrap().index; + r.clear_one(0xE0A0); + assert_eq!(r.cp_for_index(old_index), None); + r.register(0xE0A1, glyf(vec![2]), 1000).unwrap(); + assert_eq!(r.get(0xE0A1).unwrap().index, old_index); + } + + #[test] + fn clear_one_removes_registration() { + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![1]), 1000).unwrap(); + r.register(0xE0A1, glyf(vec![2]), 1000).unwrap(); + r.clear_one(0xE0A0); + assert!(!r.contains(0xE0A0)); + assert!(r.contains(0xE0A1)); + } + + #[test] + fn clear_one_unknown_is_noop() { + let r = GlyphRegistry::new(); + r.clear_one(0xE0A0); + assert!(r.is_empty()); + } + + #[test] + fn clear_all_drops_everything() { + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![1]), 1000).unwrap(); + r.register(0xE0A1, glyf(vec![2]), 1000).unwrap(); + r.clear_all(); + assert!(r.is_empty()); + } + + #[test] + fn fifo_eviction_on_capacity() { + let r = GlyphRegistry::new(); + // Fill the glossary using contiguous PUA codepoints. + for i in 0..GLOSSARY_CAPACITY as u32 { + r.register(0xE000 + i, glyf(vec![i as u8]), 1000).unwrap(); + } + assert_eq!(r.len(), GLOSSARY_CAPACITY); + + // The next register evicts the oldest (U+E000) to make room. + let evicted = r.register(0xE500, glyf(vec![0xFF]), 1000).unwrap(); + assert_eq!(evicted, Some(0xE000)); + assert!(!r.contains(0xE000)); + assert!(r.contains(0xE500)); + assert_eq!(r.len(), GLOSSARY_CAPACITY); + } + + #[test] + fn overwrite_at_capacity_does_not_evict() { + let r = GlyphRegistry::new(); + for i in 0..GLOSSARY_CAPACITY as u32 { + r.register(0xE000 + i, glyf(vec![i as u8]), 1000).unwrap(); + } + // Overwriting an existing codepoint MUST NOT evict. + let evicted = r.register(0xE000, glyf(vec![0xAB]), 1000).unwrap(); + assert_eq!(evicted, None); + assert_eq!(r.len(), GLOSSARY_CAPACITY); + assert_glyf_bytes(&r.get(0xE000).unwrap().payload, &[0xAB]); + } + + #[test] + fn registry_is_arc_shareable() { + let r1 = GlyphRegistry::new(); + let r2 = r1.clone(); + r1.register(0xE0A0, glyf(vec![1]), 1000).unwrap(); + assert!(r2.contains(0xE0A0)); + } + + #[test] + fn register_stores_colrv0_payload() { + let r = GlyphRegistry::new(); + let colr = vec![0xC0, 0x00, 0x01]; + let cpal = vec![0xCA, 0xFE]; + r.register( + 0xE0A0, + StoredPayload::ColrV0 { + glyphs: vec![vec![0xA], vec![0xB, 0xC]], + colr: colr.clone(), + cpal: cpal.clone(), + }, + 1024, + ) + .unwrap(); + match &*r.get(0xE0A0).unwrap().payload { + StoredPayload::ColrV0 { + glyphs, + colr: c, + cpal: p, + } => { + assert_eq!(glyphs.len(), 2); + assert_eq!(c, &colr); + assert_eq!(p, &cpal); + } + other => panic!("expected ColrV0, got {:?}", other), + } + } + + #[test] + fn register_stores_colrv1_payload() { + let r = GlyphRegistry::new(); + r.register( + 0x100000, + StoredPayload::ColrV1 { + glyphs: vec![vec![0xDE, 0xAD]], + colr: vec![0x01; 12], + cpal: vec![], + }, + 2048, + ) + .unwrap(); + assert!(matches!( + &*r.get(0x100000).unwrap().payload, + StoredPayload::ColrV1 { .. } + )); + } + + #[test] + fn overwrite_replaces_payload_across_formats() { + // Re-registering the same codepoint with a different format + // should swap the stored payload while preserving index and + // insertion order (FIFO eviction invariant). + let r = GlyphRegistry::new(); + r.register(0xE0A0, glyf(vec![1, 2, 3]), 1000).unwrap(); + let idx = r.get(0xE0A0).unwrap().index; + r.register( + 0xE0A0, + StoredPayload::ColrV0 { + glyphs: vec![vec![0]], + colr: vec![0x00; 4], + cpal: vec![], + }, + 1000, + ) + .unwrap(); + let g = r.get(0xE0A0).unwrap(); + assert_eq!(g.index, idx); + assert!(matches!(&*g.payload, StoredPayload::ColrV0 { .. })); + } +} diff --git a/sugarloaf/src/font/mod.rs b/sugarloaf/src/font/mod.rs index 902d1ab2..e2f1ea3e 100644 --- a/sugarloaf/src/font/mod.rs +++ b/sugarloaf/src/font/mod.rs @@ -1,5 +1,7 @@ pub mod constants; pub mod fonts; +pub mod glyf_decode; +pub mod glyph_registry; #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] pub mod linux; #[cfg(not(target_arch = "wasm32"))] @@ -243,14 +245,15 @@ impl FontLibrary { &self, ch: char, fragment_style: &SpanStyle, + route_id: Option, ) -> (usize, bool) { // Fast path: codepoint is covered by an already-registered // font. No locks upgraded, no FFI call. Shared across all // platforms — only the cascade-discovery slow path differs. - if let Some(found) = self - .inner - .read() - .find_best_font_match_strict(ch, fragment_style) + if let Some(found) = + self.inner + .read() + .find_best_font_match_strict(ch, fragment_style, route_id) { return found; } @@ -425,6 +428,61 @@ impl FontLibrary { pub fn family_names(&self) -> Vec { Vec::new() } + + /// Install a Glyph Protocol registry under `route_id`. Called once + /// per terminal session at context creation; the route_id is the + /// monotonic counter from `ROUTE_ID_COUNTER`, never reused, so the + /// installed entry's lifetime ends only when the session is + /// explicitly removed via [`Self::remove_glyph_registry`]. + /// + /// Re-installing the same `route_id` overwrites the previous + /// registry. The registry itself is Arc-shared, so subsequent + /// `register`/`clear` mutations made through the same handle are + /// visible to the renderer without re-installing. + pub fn install_glyph_registry( + &self, + route_id: usize, + registry: glyph_registry::GlyphRegistry, + ) { + self.inner + .write() + .glyph_registries + .insert(route_id, registry); + } + + /// Drop the registry for `route_id`. Called when a terminal + /// session is closed. No-op if there's no entry; safe to call + /// even from sessions that never used Glyph Protocol. + pub fn remove_glyph_registry(&self, route_id: usize) { + self.inner.write().glyph_registries.remove(&route_id); + } + + /// Read-side access for the renderer: clone the Arc handle for the + /// pane currently being drawn. Returns `None` when no program in + /// that session has touched Glyph Protocol. + #[inline] + pub fn glyph_registry_for( + &self, + route_id: usize, + ) -> Option { + self.inner.read().glyph_registries.get(&route_id).cloned() + } + + /// Does any *already-loaded* font in the library cover this + /// codepoint? Used by Glyph Protocol's `q` verb to report system + /// coverage alongside session-glossary coverage. Stays in the + /// fast path — the strict variant doesn't fire platform cascade + /// discovery, so a query never perturbs library state. Returns + /// `false` for invalid codepoints (>= 0x110000 or surrogates). + pub fn covers_codepoint(&self, cp: u32) -> bool { + let Some(ch) = char::from_u32(cp) else { + return false; + }; + self.inner + .read() + .find_best_font_match_strict(ch, &SpanStyle::default(), None) + .is_some_and(|(font_id, _)| font_id != glyph_registry::CUSTOM_GLYPH_FONT_ID) + } } impl Default for FontLibrary { @@ -484,6 +542,14 @@ pub struct FontLibraryData { /// fontconfig-/font-kit-discovered fallback against fonts already /// in the registry. postscript_to_id: FxHashMap, + /// Per-route Glyph Protocol registries, keyed by `route_id` (the + /// process-wide monotonic counter from `ROUTE_ID_COUNTER`). Each + /// terminal session installs its registry on context creation + /// and removes it on close; route_ids are never reused so a + /// stale entry can never alias a new context. Empty for windows + /// that have never seen a Glyph Protocol APC, so most callers + /// pay nothing. + glyph_registries: FxHashMap, } impl Default for FontLibraryData { @@ -494,6 +560,7 @@ impl Default for FontLibraryData { symbol_maps: None, primary_metrics_cache: FxHashMap::default(), postscript_to_id: FxHashMap::default(), + glyph_registries: FxHashMap::default(), } } } @@ -504,7 +571,24 @@ impl FontLibraryData { &self, ch: char, fragment_style: &SpanStyle, + route_id: Option, ) -> Option<(usize, bool)> { + // Glyph Protocol override takes precedence over everything + // else — if an application has registered this codepoint in + // *this pane's* registry, the registration is what the user + // is asking to see. Each pane consults its own registry via + // route_id, so two panes can host different programs with + // overlapping PUA registrations without interfering. Checked + // before symbol maps and the font fallback chain because + // neither of those should "beat" an explicit registration. + if let Some(route_id) = route_id { + if let Some(registry) = self.glyph_registries.get(&route_id) { + if registry.contains(ch as u32) { + return Some((glyph_registry::CUSTOM_GLYPH_FONT_ID, false)); + } + } + } + let mut synth = Synthesis::default(); let mut char_cluster = CharCluster::new(); let mut parser = Parser::new( @@ -554,7 +638,21 @@ impl FontLibraryData { &self, ch: char, fragment_style: &SpanStyle, + route_id: Option, ) -> Option<(usize, bool)> { + // Glyph Protocol short-circuit, same precedence as + // find_best_font_match — the strict variant is the fast-path + // entry point used by `resolve_font_for_char` so it must also + // honour registrations or codepoints that match a registered + // glyph would briefly fall through to system font discovery. + if let Some(route_id) = route_id { + if let Some(registry) = self.glyph_registries.get(&route_id) { + if registry.contains(ch as u32) { + return Some((glyph_registry::CUSTOM_GLYPH_FONT_ID, false)); + } + } + } + let mut synth = Synthesis::default(); let mut char_cluster = CharCluster::new(); let mut parser = Parser::new( @@ -1958,7 +2056,7 @@ mod postscript_resolver_tests { // U+6C34 ('水') — not in CascadiaMono. Library has no fallback // registered, so the pre-resolve walk returns None and the // discovery path has to fire. - let (font_id, _is_emoji) = lib.resolve_font_for_char('\u{6C34}', &style); + let (font_id, _is_emoji) = lib.resolve_font_for_char('\u{6C34}', &style, None); assert_ne!( font_id, 0, @@ -1993,9 +2091,9 @@ mod postscript_resolver_tests { // Both codepoints should cascade to the same system CJK font on // any stock macOS install. - let (id_a, _) = lib.resolve_font_for_char('\u{6C34}', &style); + let (id_a, _) = lib.resolve_font_for_char('\u{6C34}', &style, None); let len_after_first = lib.inner.read().inner.len(); - let (id_b, _) = lib.resolve_font_for_char('\u{6728}', &style); + let (id_b, _) = lib.resolve_font_for_char('\u{6728}', &style, None); let len_after_second = lib.inner.read().inner.len(); assert_eq!( @@ -2008,3 +2106,65 @@ mod postscript_resolver_tests { ); } } + +#[cfg(test)] +mod glyph_registry_install_tests { + use super::*; + use crate::font::glyph_registry::GlyphRegistry; + + #[test] + fn install_then_lookup_returns_same_arc() { + let library = FontLibrary::default(); + let registry = GlyphRegistry::new(); + library.install_glyph_registry(42, registry.clone()); + + let fetched = library + .glyph_registry_for(42) + .expect("entry installed at 42"); + assert!(fetched.ptr_eq(®istry)); + } + + #[test] + fn lookup_returns_none_for_unknown_route() { + let library = FontLibrary::default(); + assert!(library.glyph_registry_for(999).is_none()); + } + + #[test] + fn install_overwrites_same_route() { + let library = FontLibrary::default(); + let first = GlyphRegistry::new(); + let second = GlyphRegistry::new(); + assert!(!first.ptr_eq(&second)); + + library.install_glyph_registry(7, first.clone()); + library.install_glyph_registry(7, second.clone()); + + let fetched = library.glyph_registry_for(7).expect("entry at 7"); + assert!(fetched.ptr_eq(&second)); + assert!(!fetched.ptr_eq(&first)); + } + + #[test] + fn remove_drops_the_entry() { + let library = FontLibrary::default(); + let registry = GlyphRegistry::new(); + library.install_glyph_registry(3, registry); + assert!(library.glyph_registry_for(3).is_some()); + + library.remove_glyph_registry(3); + assert!(library.glyph_registry_for(3).is_none()); + } + + #[test] + fn distinct_routes_hold_distinct_registries() { + let library = FontLibrary::default(); + let a = GlyphRegistry::new(); + let b = GlyphRegistry::new(); + library.install_glyph_registry(1, a.clone()); + library.install_glyph_registry(2, b.clone()); + + assert!(library.glyph_registry_for(1).unwrap().ptr_eq(&a)); + assert!(library.glyph_registry_for(2).unwrap().ptr_eq(&b)); + } +} diff --git a/sugarloaf/src/font_cache.rs b/sugarloaf/src/font_cache.rs index 6b0152f8..be24667c 100644 --- a/sugarloaf/src/font_cache.rs +++ b/sugarloaf/src/font_cache.rs @@ -124,7 +124,10 @@ pub(crate) fn resolve_with( // discovery (CoreText on macOS, fontconfig on Linux, font-kit walk // on Windows) and registers the discovered font on first miss so // subsequent codepoints in the same script hit the fast path. - let (font_id, is_emoji) = font_lib.resolve_font_for_char(ch, &style); + // No route context here — this cache is for layout/width measurement, + // not pane rendering. PUA codepoints fall through to the regular + // font lookup, which returns a sensible width. + let (font_id, is_emoji) = font_lib.resolve_font_for_char(ch, &style, None); if is_emoji { width = 2.0; diff --git a/sugarloaf/src/lib.rs b/sugarloaf/src/lib.rs index fd074cfa..35cfc3b4 100644 --- a/sugarloaf/src/lib.rs +++ b/sugarloaf/src/lib.rs @@ -8,6 +8,20 @@ pub mod renderer; mod sugarloaf; pub mod text; +/// Single public entry point for the Glyph Protocol — registry types, +/// rasteriser, and atlas-key helpers. Wraps the relevant pieces from +/// `font::glyph_registry` and `renderer::image_cache::colr_raster` so +/// downstream crates have one stable place to import from. +pub mod glyph_protocol { + pub use crate::font::glyph_registry::{ + is_pua, pack_atlas_glyph_id, GlyphRegistry, RegisterRejection, RegisteredGlyph, + StoredPayload, CUSTOM_GLYPH_FONT_ID, CUSTOM_GLYPH_FONT_ID_U32, GLOSSARY_CAPACITY, + }; + pub use crate::renderer::image_cache::colr_raster::{ + rasterize_payload, RasterizedPayload, + }; +} + // Re-export upstream swash so call sites can use `sugarloaf::swash::*`. // This path was used by the in-tree fork; preserve it for stability. pub use swash; diff --git a/sugarloaf/src/renderer/image_cache/colr_raster.rs b/sugarloaf/src/renderer/image_cache/colr_raster.rs new file mode 100644 index 00000000..c5173189 --- /dev/null +++ b/sugarloaf/src/renderer/image_cache/colr_raster.rs @@ -0,0 +1,1077 @@ +// CPU rasteriser for OpenType COLR v0 / v1 paint graphs. +// +// Drives ttf-parser's `colr::Painter` trait against a `tiny-skia` +// backend. The result is an RGBA8 bitmap that the caller uploads +// into sugarloaf's colour atlas — so the same code path works across +// all three backends (Wgpu, native Metal, Cpu) since rasterisation +// happens on CPU and only the upload is backend-specific. +// +// Correctness budget: +// * Linear + radial gradients are handled correctly, including the +// 3-point → 2-point projection COLR v1 requires (porting the +// math from skrifa/color/traversal.rs). +// * Sweep gradients degrade to the first stop's solid colour. +// * Variable-font coordinates render at the default instance. +// * Composite modes beyond the painter's-algorithm `SrcOver` map +// through `CompositeMode → BlendMode`; tiny-skia implements the +// full set so there's no loss, but we pass them through rather +// than validating each one per font. +// +// Rasterisation is cached per `(codepoint, pixel_size)` upstream by +// the existing glyph cache, so a 1-2 ms CPU pass per unique glyph +// size is invisible to the user. + +use ttf_parser::colr::{ + ClipBox, CompositeMode, GradientExtend, LinearGradient as TtfLinear, Paint, Painter, + RadialGradient as TtfRadial, +}; +use ttf_parser::{GlyphId, RgbaColor, Transform as TtfTransform}; + +use tiny_skia::{ + BlendMode, Color, FillRule, GradientStop, LinearGradient, Mask, Paint as SkPaint, + Path, PathBuilder, Pixmap, PixmapPaint, Point, RadialGradient, Rect, Shader, + SpreadMode, Transform, +}; + +use crate::font::glyf_decode; + +/// Bitmap + placement metadata produced by [`rasterize_payload`] (and +/// internally by [`rasterize`]). `is_color` distinguishes the two +/// underlying formats: `false` → A8 alpha mask (mono `glyf`), `true` +/// → straight RGBA8 (`colrv0` / `colrv1`). The grid renderer routes +/// mono entries to the grayscale atlas and colour entries to the +/// colour atlas based on this flag. +pub struct RasterizedPayload { + pub data: Vec, + pub width: u16, + pub height: u16, + /// Pixel offset from the cell's pen position to the bitmap's + /// left edge. + pub left: i32, + /// Pixel offset from the baseline to the bitmap's top edge + /// (positive = baseline below the top). + pub top: i32, + pub is_color: bool, +} + +/// Rasterise a registered Glyph Protocol payload. Dispatches the +/// monochrome `glyf` path through tiny-skia's anti-aliased fill (A8 +/// output) and the colour `colrv0`/`colrv1` paths through the COLR +/// painter graph (RGBA8 output). Returns `None` on malformed payload +/// or degenerate sizing. +pub fn rasterize_payload( + payload: &crate::font::glyph_registry::StoredPayload, + upm: u16, + pixel_size: u16, + foreground_rgba: [u8; 4], +) -> Option { + use crate::font::glyph_registry::StoredPayload; + match payload { + StoredPayload::Glyf { glyf } => rasterize_mono(glyf, upm, pixel_size), + StoredPayload::ColrV0 { glyphs, colr, cpal } + | StoredPayload::ColrV1 { glyphs, colr, cpal } => { + rasterize(glyphs, colr, cpal, upm, pixel_size, foreground_rgba) + } + } +} + +/// Walk a `glyf` simple-glyph outline and rasterise it as an A8 alpha +/// mask sized to fit `pixel_size`. The atlas-bound caller uploads +/// the bytes straight into the grayscale atlas, same shape as the +/// swash/CT mono path produces. +fn rasterize_mono(glyf: &[u8], upm: u16, pixel_size: u16) -> Option { + if pixel_size == 0 || upm == 0 { + return None; + } + + let outline = glyf_decode::decode(glyf).ok()?; + let scale = pixel_size as f32 / upm as f32; + + let pad = 1.0_f32; + let pix_w = (((outline.x_max - outline.x_min) as f32 * scale).ceil() + pad * 2.0) + .max(1.0) as u32; + let pix_h = (((outline.y_max - outline.y_min) as f32 * scale).ceil() + pad * 2.0) + .max(1.0) as u32; + + // `glyf_decode::Outline::walk` flips Y so its output is Y-down + // with origin at the top of the bbox (y=0 → top, y increases + // downward to `y_max - y_min`). That matches tiny-skia's pixmap + // convention exactly, so we feed walk's coords straight in + // without further flipping. The COLR rasteriser un-flips because + // its painter expects Y-up design units; this monochrome path + // skips the painter and goes pixmap-direct. + let cmds = outline.walk(1, 1.0); + if cmds.is_empty() { + return None; + } + let mut pb = PathBuilder::new(); + for cmd in &cmds { + match *cmd { + glyf_decode::PathCmd::MoveTo { x, y } => pb.move_to(x, y), + glyf_decode::PathCmd::LineTo { x, y } => pb.line_to(x, y), + glyf_decode::PathCmd::QuadTo { cx, cy, x, y } => pb.quad_to(cx, cy, x, y), + glyf_decode::PathCmd::Close => pb.close(), + } + } + let path = pb.finish()?; + + let mut pixmap = Pixmap::new(pix_w, pix_h)?; + // X: shift so design `x_min` lands at pixel `pad`. + // Y: walk already puts the bbox top at y=0, so a flat `pad` + // offset places the top of the glyph one px below the pixmap + // top edge. + let ctm = Transform::from_row( + scale, + 0.0, + 0.0, + scale, + pad - outline.x_min as f32 * scale, + pad, + ); + let mut paint = SkPaint::default(); + paint.set_color_rgba8(0xFF, 0xFF, 0xFF, 0xFF); + paint.anti_alias = true; + pixmap.fill_path(&path, &paint, FillRule::Winding, ctm, None); + + // Pixmap stores premultiplied RGBA; for an A8 mask we just take + // the alpha channel (which equals R/G/B since we filled white). + let data: Vec = pixmap.pixels().iter().map(|p| p.alpha()).collect(); + + // Placement: `floor` left and `ceil` top to expand outward by a + // sub-pixel and avoid clipping anti-aliased edges, matching the + // COLR rasteriser's convention. The bitmap's top edge sits at + // design-unit `y_max` in baseline-up convention. + let left = (outline.x_min as f32 * scale - pad).floor() as i32; + let top = (outline.y_max as f32 * scale + pad).ceil() as i32; + + Some(RasterizedPayload { + data, + width: pix_w as u16, + height: pix_h as u16, + left, + top, + is_color: false, + }) +} + +/// Rasterise a COLR glyph to RGBA. Returns `None` when COLR/CPAL is +/// malformed, when the base-glyph outline is empty, or when tiny-skia +/// rejects a degenerate configuration (e.g. zero pixmap size). +pub(super) fn rasterize( + glyphs: &[Vec], + colr_bytes: &[u8], + cpal_bytes: &[u8], + upm: u16, + pixel_size: u16, + foreground: [u8; 4], +) -> Option { + if pixel_size == 0 || upm == 0 { + return None; + } + + // ttf-parser's `colr::Table::parse` requires a non-empty CPAL + // slice, even for v1 fonts that make no palette lookups. If the + // container ships an empty CPAL (legal for v1-only paints), feed + // the parser a zero-entry placeholder. + const EMPTY_CPAL: [u8; 12] = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, + ]; + let cpal_source: &[u8] = if cpal_bytes.is_empty() { + &EMPTY_CPAL + } else { + cpal_bytes + }; + let cpal = ttf_parser::cpal::Table::parse(cpal_source)?; + let colr = ttf_parser::colr::Table::parse(cpal, colr_bytes)?; + let base_gid = first_base_glyph_id(colr_bytes, glyphs)?; + + // Prefer the COLR ClipBox — authoritative per the OpenType spec, + // and required for emoji fonts (Noto Color Emoji, etc.) whose base + // glyphs are empty wrappers that reference layer glyphs via the + // paint graph. Fall back to the base glyph's `glyf` bbox for fonts + // like Nabla that carry geometry on the base glyph. Pad 1 px each + // side so anti-aliased layer edges that drift slightly past the + // declared bbox (common in hand-authored fonts) aren't clipped. + // + // Widened to i32 immediately because a saturated ClipBox (e.g. + // `x_min = i16::MIN`, `x_max = i16::MAX`) would overflow on the + // `x_max - x_min` subtraction below if kept as i16 — wrapping in + // release and panicking in debug. + let (x_min, y_min, x_max, y_max): (i32, i32, i32, i32) = + match colr.clip_box(GlyphId(base_gid), &[]) { + Some(cb) => ( + cb.x_min.floor() as i32, + cb.y_min.floor() as i32, + cb.x_max.ceil() as i32, + cb.y_max.ceil() as i32, + ), + None => { + let (a, b, c, d) = glyf_bbox(glyphs.get(base_gid as usize)?)?; + (a as i32, b as i32, c as i32, d as i32) + } + }; + let scale = pixel_size as f32 / upm as f32; + + let pad = 1.0_f32; + let pix_w = (((x_max - x_min) as f32 * scale).ceil() + pad * 2.0).max(1.0) as u32; + let pix_h = (((y_max - y_min) as f32 * scale).ceil() + pad * 2.0).max(1.0) as u32; + + let base_pixmap = Pixmap::new(pix_w, pix_h)?; + + // Font units (y-up, origin at baseline) → pixmap pixels (y-down, + // origin at top-left). The identity-sized glyph outline in the + // painter gets this transform applied before drawing. + // Matrix [sx, ky, kx, sy, tx, ty] applies (x,y) as + // (sx*x + kx*y + tx, ky*x + sy*y + ty). + let base_ctm = Transform::from_row( + scale, + 0.0, + 0.0, + -scale, + -(x_min as f32) * scale + pad, + (y_max as f32) * scale + pad, + ); + + let mut raster = ColorRaster { + layers: vec![Layer { + pixmap: base_pixmap, + mode: CompositeMode::SourceOver, + }], + transforms: vec![base_ctm], + clips: vec![None], + current_path: None, + glyphs, + }; + + let fg = RgbaColor::new(foreground[0], foreground[1], foreground[2], foreground[3]); + colr.paint(GlyphId(base_gid), 0, &mut raster, &[], fg)?; + + debug_assert_eq!(raster.layers.len(), 1, "layer stack should drain"); + let final_pixmap = raster.layers.pop().unwrap().pixmap; + + Some(RasterizedPayload { + data: pixmap_to_rgba(&final_pixmap), + width: pix_w as u16, + height: pix_h as u16, + left: (x_min as f32 * scale - pad).floor() as i32, + top: (y_max as f32 * scale + pad).ceil() as i32, + is_color: true, + }) +} + +struct Layer { + pixmap: Pixmap, + mode: CompositeMode, +} + +struct ColorRaster<'a> { + layers: Vec, + transforms: Vec, + clips: Vec>, + current_path: Option, + glyphs: &'a [Vec], +} + +impl ColorRaster<'_> { + fn top_ctm(&self) -> Transform { + *self.transforms.last().unwrap_or(&Transform::identity()) + } + + fn top_clip(&self) -> Option<&Mask> { + self.clips.last().and_then(|c| c.as_ref()) + } + + fn top_pixmap(&mut self) -> &mut Pixmap { + &mut self.layers.last_mut().unwrap().pixmap + } + + fn fill_current(&mut self, paint: SkPaint) { + let Some(path) = self.current_path.clone() else { + return; + }; + let ctm = self.top_ctm(); + // Clone the clip mask so we can borrow the pixmap mutably. + let clip = self.top_clip().cloned(); + let pixmap = self.top_pixmap(); + pixmap.fill_path(&path, &paint, FillRule::Winding, ctm, clip.as_ref()); + } +} + +impl<'a> Painter<'a> for ColorRaster<'a> { + fn outline_glyph(&mut self, glyph_id: GlyphId) { + let idx = glyph_id.0 as usize; + let Some(bytes) = self.glyphs.get(idx) else { + self.current_path = None; + return; + }; + if bytes.is_empty() { + self.current_path = None; + return; + } + self.current_path = build_path(bytes); + } + + fn paint(&mut self, paint: Paint<'a>) { + match paint { + Paint::Solid(color) => { + let p = SkPaint { + shader: Shader::SolidColor(rgba_to_color(color)), + anti_alias: true, + ..SkPaint::default() + }; + self.fill_current(p); + } + Paint::LinearGradient(lg) => { + if let Some(shader) = linear_gradient_shader(&lg) { + let p = SkPaint { + shader, + anti_alias: true, + ..SkPaint::default() + }; + self.fill_current(p); + } + } + Paint::RadialGradient(rg) => { + if let Some(shader) = radial_gradient_shader(&rg) { + let p = SkPaint { + shader, + anti_alias: true, + ..SkPaint::default() + }; + self.fill_current(p); + } + } + Paint::SweepGradient(sg) => { + // Sweep gradients don't map to tiny-skia (no sweep + // shader). Degrade to the first stop's solid colour. + // Nabla doesn't use sweeps; extending later means + // writing a custom per-pixel shader. + if let Some(first) = sg.stops(0, &[]).next() { + let p = SkPaint { + shader: Shader::SolidColor(rgba_to_color(first.color)), + anti_alias: true, + ..SkPaint::default() + }; + self.fill_current(p); + } + } + } + } + + fn push_clip(&mut self) { + let Some(path) = self.current_path.clone() else { + // Keep the stack height balanced for the matching pop. + self.clips.push(self.top_clip().cloned()); + return; + }; + let ctm = self.top_ctm(); + let parent = self.top_clip().cloned(); + let (pw, ph) = { + let p = self.top_pixmap(); + (p.width(), p.height()) + }; + let Some(mut mask) = Mask::new(pw, ph) else { + self.clips.push(parent); + return; + }; + mask.fill_path(&path, FillRule::Winding, true, ctm); + if let Some(par) = parent { + intersect_masks(&mut mask, &par); + } + self.clips.push(Some(mask)); + } + + fn push_clip_box(&mut self, clipbox: ClipBox) { + let parent = self.top_clip().cloned(); + let (pw, ph) = { + let p = self.top_pixmap(); + (p.width(), p.height()) + }; + let Some(rect) = + Rect::from_ltrb(clipbox.x_min, clipbox.y_min, clipbox.x_max, clipbox.y_max) + else { + self.clips.push(parent); + return; + }; + let path = PathBuilder::from_rect(rect); + let ctm = self.top_ctm(); + let Some(mut mask) = Mask::new(pw, ph) else { + self.clips.push(parent); + return; + }; + mask.fill_path(&path, FillRule::Winding, true, ctm); + if let Some(par) = parent { + intersect_masks(&mut mask, &par); + } + self.clips.push(Some(mask)); + } + + fn pop_clip(&mut self) { + self.clips.pop(); + } + + fn push_layer(&mut self, mode: CompositeMode) { + let (w, h) = { + let base = self.top_pixmap(); + (base.width(), base.height()) + }; + let Some(pixmap) = Pixmap::new(w, h) else { + // Out of memory — push a token entry so pop_layer stays + // balanced. Drawing will fail silently until the pop. + self.layers.push(Layer { + pixmap: Pixmap::new(1, 1).unwrap(), + mode, + }); + self.clips.push(self.top_clip().cloned()); + return; + }; + self.layers.push(Layer { pixmap, mode }); + // Layers inherit the enclosing clip. Every push_layer is + // paired with a pop_layer; we push a matching clip entry so + // the stack heights stay in lock-step. + self.clips.push(self.top_clip().cloned()); + } + + fn pop_layer(&mut self) { + let Some(top) = self.layers.pop() else { return }; + self.clips.pop(); + let blend = composite_mode_to_blend(top.mode); + let Some(parent) = self.layers.last_mut() else { + // Stack imbalance — should be unreachable given + // ttf-parser's own push/pop pairing. + return; + }; + parent.pixmap.draw_pixmap( + 0, + 0, + top.pixmap.as_ref(), + &PixmapPaint { + opacity: 1.0, + blend_mode: blend, + quality: tiny_skia::FilterQuality::Nearest, + }, + Transform::identity(), + None, + ); + } + + fn push_transform(&mut self, transform: TtfTransform) { + let t = Transform::from_row( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ); + let ctm = self.top_ctm().pre_concat(t); + self.transforms.push(ctm); + } + + fn pop_transform(&mut self) { + self.transforms.pop(); + } +} + +/// Parse the COLR header's base-glyph records and return the first +/// one whose outline slot in `glyphs` is non-empty. +/// +/// Naive "take record 0" doesn't work: fontTools sorts `BaseGlyphList` +/// by glyphID and keeps a `BaseGlyphPaintRecord` for `.notdef` (GID +/// 0), which has an empty outline after subsetting. We need to skip +/// past those empty slots and find the first record that actually +/// has ink. Prefers v1's `BaseGlyphList`, falls back to v0's +/// `BaseGlyphRecord` array. +fn first_base_glyph_id(colr: &[u8], glyphs: &[Vec]) -> Option { + if colr.len() < 8 { + return None; + } + let is_non_empty = + |gid: u16| -> bool { glyphs.get(gid as usize).is_some_and(|g| !g.is_empty()) }; + + // v1 BaseGlyphList: u32 numRecords, then records of + // { u16 glyphID, u32 paintOffset } = 6 bytes each. + if colr.len() >= 18 { + let v1_off = + u32::from_be_bytes([colr[14], colr[15], colr[16], colr[17]]) as usize; + if v1_off != 0 && v1_off + 4 <= colr.len() { + let num_records = u32::from_be_bytes([ + colr[v1_off], + colr[v1_off + 1], + colr[v1_off + 2], + colr[v1_off + 3], + ]) as usize; + let mut first_gid = None; + for i in 0..num_records { + let rec_off = v1_off + 4 + i * 6; + if rec_off + 2 > colr.len() { + break; + } + let gid = u16::from_be_bytes([colr[rec_off], colr[rec_off + 1]]); + first_gid.get_or_insert(gid); + if is_non_empty(gid) { + return Some(gid); + } + } + // No non-empty record found. Return the first one we saw + // so the caller still has something; the subsequent bbox + // read will bail cleanly. + if let Some(g) = first_gid { + return Some(g); + } + } + } + + // v0 BaseGlyphRecord array: { u16 glyphID, u16 firstLayer, u16 numLayers } = 6 B. + let num_v0 = u16::from_be_bytes([colr[2], colr[3]]) as usize; + let v0_off = u32::from_be_bytes([colr[4], colr[5], colr[6], colr[7]]) as usize; + let mut first_gid = None; + for i in 0..num_v0 { + let rec_off = v0_off + i * 6; + if rec_off + 2 > colr.len() { + break; + } + let gid = u16::from_be_bytes([colr[rec_off], colr[rec_off + 1]]); + first_gid.get_or_insert(gid); + if is_non_empty(gid) { + return Some(gid); + } + } + first_gid +} + +fn glyf_bbox(bytes: &[u8]) -> Option<(i16, i16, i16, i16)> { + if bytes.len() < 10 { + return None; + } + let xmin = i16::from_be_bytes([bytes[2], bytes[3]]); + let ymin = i16::from_be_bytes([bytes[4], bytes[5]]); + let xmax = i16::from_be_bytes([bytes[6], bytes[7]]); + let ymax = i16::from_be_bytes([bytes[8], bytes[9]]); + Some((xmin, ymin, xmax, ymax)) +} + +/// Decode a glyf simple-glyph record into an unscaled, Y-up +/// design-unit `Path`. The painter's CTM is responsible for the +/// scale + Y-flip at draw time. +/// +/// `glyf_decode::Outline::walk` already flips Y so its output sits +/// in Y-down origin-at-y_max space. For the COLR painter we want +/// pristine design-unit Y-up coordinates (so paint-graph transforms +/// compose correctly), so we un-flip walk's output by subtracting +/// from `y_max`. Equivalent to a dedicated y-preserving walker, but +/// reuses `walk`'s existing implied-on-curve handling. +fn build_path(bytes: &[u8]) -> Option { + let outline = glyf_decode::decode(bytes).ok()?; + let y_max = outline.y_max as f32; + // `walk(upm=1, size=1.0)` gives us an identity scale, so every + // coord out is `design_x, y_max - design_y`. Un-flip Y below. + let cmds = outline.walk(1, 1.0); + if cmds.is_empty() { + return None; + } + let unflip = |y: f32| y_max - y; + let mut pb = PathBuilder::new(); + for cmd in &cmds { + match *cmd { + glyf_decode::PathCmd::MoveTo { x, y } => pb.move_to(x, unflip(y)), + glyf_decode::PathCmd::LineTo { x, y } => pb.line_to(x, unflip(y)), + glyf_decode::PathCmd::QuadTo { cx, cy, x, y } => { + pb.quad_to(cx, unflip(cy), x, unflip(y)) + } + glyf_decode::PathCmd::Close => pb.close(), + } + } + pb.finish() +} + +/// tiny-skia Pixmap pixels are premultiplied. The v4 grid color atlas +/// expects premultiplied RGBA — its Metal/wgpu/vulkan pipelines all +/// configure source-blend = `One`, dest-blend = `OneMinusSourceAlpha`, +/// matching `MTLSamplerAddressMode`/system-emoji rasteriser conventions. +/// So pass the bytes through verbatim. (The previous PR plumbed COLR +/// glyphs through the rich-text image-cache, whose pipeline used +/// `SourceAlpha + OneMinusSourceAlpha` and therefore wanted straight +/// alpha — that path no longer exists in main.) +fn pixmap_to_rgba(pixmap: &Pixmap) -> Vec { + let pixels = pixmap.pixels(); + let mut out = Vec::with_capacity(pixels.len() * 4); + for p in pixels { + out.push(p.red()); + out.push(p.green()); + out.push(p.blue()); + out.push(p.alpha()); + } + out +} + +fn rgba_to_color(c: RgbaColor) -> Color { + Color::from_rgba8(c.red, c.green, c.blue, c.alpha) +} + +/// Collect + sort COLR stops. Returns the raw `(offset, color)` +/// pairs so the caller can still pull the first stop's colour for +/// single-stop degeneracy (tiny-skia's `GradientStop` fields are +/// `pub(crate)` and don't expose the colour back). +fn collect_stops( + iter: impl Iterator, +) -> Vec<(f32, Color)> { + let mut stops: Vec<(f32, Color)> = iter + .map(|s| (s.stop_offset, rgba_to_color(s.color))) + .collect(); + stops.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + stops +} + +fn stops_to_tiny_skia(stops: &[(f32, Color)]) -> Vec { + stops + .iter() + .map(|&(o, c)| GradientStop::new(o, c)) + .collect() +} + +fn extend_to_spread(e: GradientExtend) -> SpreadMode { + match e { + GradientExtend::Pad => SpreadMode::Pad, + GradientExtend::Repeat => SpreadMode::Repeat, + GradientExtend::Reflect => SpreadMode::Reflect, + } +} + +/// Project COLR's 3-point linear-gradient form to the 2-point form +/// tiny-skia wants. Returns `P3`, the point on the perpendicular to +/// `P0→P2` through `P0` that corresponds to `P1`. +/// +/// Two equivalent formulations of the same geometry are in the wild: +/// +/// - **skrifa/nanoemoji**: `P3 = P0 + project(P1 - P0, perp(P2 - P0))` +/// — project onto the perpendicular axis, add back to P0. +/// - **This impl**: `P3 = P1 - t * (P2 - P0)` where +/// `t = ((P1 - P0) · (P2 - P0)) / |P2 - P0|²` +/// — remove the parallel component from `(P1 - P0)` and add P0. +/// +/// Algebraically these give the same point: subtracting the parallel +/// component of `(P1 - P0)` leaves its perpendicular component, and +/// `P0 + perp_component = P1 - parallel_component`. The FreeType +/// COLRv1 reference implementation uses the second formulation. +/// +/// Returns `None` if `P0 == P2` (degenerate axis with no direction). +fn project_p3(p0: (f32, f32), p1: (f32, f32), p2: (f32, f32)) -> Option<(f32, f32)> { + let dx = p2.0 - p0.0; + let dy = p2.1 - p0.1; + let len_sq = dx * dx + dy * dy; + if !len_sq.is_finite() || len_sq < 1e-6 { + return None; + } + let bx = p1.0 - p0.0; + let by = p1.1 - p0.1; + let t = (bx * dx + by * dy) / len_sq; + Some((p1.0 - t * dx, p1.1 - t * dy)) +} + +/// Build a tiny-skia linear-gradient shader for a COLR linear paint. +/// The 3-point → 2-point projection happens via [`project_p3`]. +/// +/// Stop normalisation (extending the P0-P3 line when stops sit +/// outside `[0, 1]`) is NOT performed: tiny-skia clamps stop offsets +/// to `[0, 1]`, so a gradient with stops at e.g. `-0.2`..`1.2` +/// renders truncated at the boundaries. Nabla's stops sit within +/// `[0, 1]` so this hasn't bitten the demo. Handling wide stops +/// would mean moving `P0` and `P3` outward by the offset overhang +/// and rescaling stops to fit `[0, 1]`; skrifa's traversal.rs has +/// the full math. +fn linear_gradient_shader(lg: &TtfLinear<'_>) -> Option> { + let p0 = (lg.x0, lg.y0); + let p1 = (lg.x1, lg.y1); + let p2 = (lg.x2, lg.y2); + let (p3x, p3y) = project_p3(p0, p1, p2)?; + + let stops = collect_stops(lg.stops(0, &[])); + if stops.len() < 2 { + return stops.into_iter().next().map(|(_, c)| Shader::SolidColor(c)); + } + + LinearGradient::new( + Point::from_xy(p0.0, p0.1), + Point::from_xy(p3x, p3y), + stops_to_tiny_skia(&stops), + extend_to_spread(lg.extend), + Transform::identity(), + ) +} + +fn radial_gradient_shader(rg: &TtfRadial<'_>) -> Option> { + let stops = collect_stops(rg.stops(0, &[])); + if stops.len() < 2 { + return stops.into_iter().next().map(|(_, c)| Shader::SolidColor(c)); + } + RadialGradient::new( + Point::from_xy(rg.x0, rg.y0), + rg.r0.max(0.0), + Point::from_xy(rg.x1, rg.y1), + rg.r1.max(0.1), + stops_to_tiny_skia(&stops), + extend_to_spread(rg.extend), + Transform::identity(), + ) +} + +fn composite_mode_to_blend(mode: CompositeMode) -> BlendMode { + use CompositeMode::*; + match mode { + Clear => BlendMode::Clear, + Source => BlendMode::Source, + Destination => BlendMode::Destination, + SourceOver => BlendMode::SourceOver, + DestinationOver => BlendMode::DestinationOver, + SourceIn => BlendMode::SourceIn, + DestinationIn => BlendMode::DestinationIn, + SourceOut => BlendMode::SourceOut, + DestinationOut => BlendMode::DestinationOut, + SourceAtop => BlendMode::SourceAtop, + DestinationAtop => BlendMode::DestinationAtop, + Xor => BlendMode::Xor, + Plus => BlendMode::Plus, + Screen => BlendMode::Screen, + Overlay => BlendMode::Overlay, + Darken => BlendMode::Darken, + Lighten => BlendMode::Lighten, + ColorDodge => BlendMode::ColorDodge, + ColorBurn => BlendMode::ColorBurn, + HardLight => BlendMode::HardLight, + SoftLight => BlendMode::SoftLight, + Difference => BlendMode::Difference, + Exclusion => BlendMode::Exclusion, + Multiply => BlendMode::Multiply, + Hue => BlendMode::Hue, + Saturation => BlendMode::Saturation, + Color => BlendMode::Color, + Luminosity => BlendMode::Luminosity, + } +} + +/// Intersect two 8-bit alpha masks in place: `dst = dst ∩ src`. +/// Used when pushing nested clips — the new clip region is the +/// logical intersection of the outer and inner paths. Both masks +/// share dimensions by construction (we always build them from +/// the current pixmap's size). +fn intersect_masks(dst: &mut Mask, src: &Mask) { + if dst.width() != src.width() || dst.height() != src.height() { + return; + } + let dst_bytes = dst.data_mut(); + let src_bytes = src.data(); + for (d, &s) in dst_bytes.iter_mut().zip(src_bytes.iter()) { + *d = ((*d as u16 * s as u16) / 255) as u8; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: vector dot product in 2D. + fn dot(a: (f32, f32), b: (f32, f32)) -> f32 { + a.0 * b.0 + a.1 * b.1 + } + + /// Build a `glyf` simple-glyph whose bbox is the full `em_top × em_top` + /// square but whose inked region is just the top `strip_height` rows + /// (design Y from `em_top - strip_height` to `em_top`). The bbox in + /// the glyf header is authoritative — `glyf_decode` reads it directly + /// — so the rasterised pixmap will be em_top×em_top pixels with only + /// the top strip filled. That's what lets the test distinguish "top" + /// from "bottom" of the bitmap. + fn glyf_top_strip(em_top: i16, strip_height: i16) -> Vec { + // glyf simple-glyph layout per OpenType: + // i16 numberOfContours + // i16 xMin, yMin, xMax, yMax (authoritative bbox — NOT derived from points) + // u16 endPtsOfContours[numContours] + // u16 instructionLength + // u8 flags[numPoints] + // coords (deltas, big-endian i16 when not using shorts) + let strip_bottom = em_top - strip_height; + let mut v = Vec::new(); + v.extend_from_slice(&1i16.to_be_bytes()); // numberOfContours + // Declare bbox as the full em — not just the inked strip — so + // the rasterised pixmap has empty space below the strip we + // can sample as "bottom". + v.extend_from_slice(&0i16.to_be_bytes()); // xMin + v.extend_from_slice(&0i16.to_be_bytes()); // yMin + v.extend_from_slice(&em_top.to_be_bytes()); // xMax + v.extend_from_slice(&em_top.to_be_bytes()); // yMax + v.extend_from_slice(&3u16.to_be_bytes()); // endPtsOfContours[0] = 3 (4 points) + v.extend_from_slice(&0u16.to_be_bytes()); // instructionLength = 0 + v.extend_from_slice(&[0x01; 4]); // 4 flags, all on-curve, full i16 deltas + // Points walk the rectangle [0, strip_bottom] → [em_top, strip_bottom] + // → [em_top, em_top] → [0, em_top]. Deltas from previous point + // (first delta is from origin (0,0)). + let xs = [0i16, em_top, 0, -em_top]; + let ys = [strip_bottom, 0, strip_height, 0]; + for x in &xs { + v.extend_from_slice(&x.to_be_bytes()); + } + for y in &ys { + v.extend_from_slice(&y.to_be_bytes()); + } + v + } + + #[test] + fn rasterize_mono_top_pixels_filled_bottom_pixels_empty() { + // Outline occupies only the top 25% of the em — after raster, + // the top rows of the bitmap should be inked and the bottom + // rows should be transparent. Catches the Y-flip bug we hit + // earlier (where the strip rendered at the bottom instead). + let upm = 100i16; + let bytes = glyf_top_strip(upm, upm / 4); + let r = rasterize_mono(&bytes, upm as u16, upm as u16) + .expect("rasterize succeeds for valid simple glyph"); + + let w = r.width as usize; + let h = r.height as usize; + assert!(w > 4 && h > 4, "bitmap should be larger than the padding"); + + // Sample a row near the top (just below the 1-px pad) and a + // row near the bottom. Use the centre column to avoid the + // padding strip on the sides. + let mid_x = w / 2; + let top_y = 2; + let bot_y = h - 2; + let top_alpha = r.data[top_y * w + mid_x]; + let bot_alpha = r.data[bot_y * w + mid_x]; + + assert!( + top_alpha > 0, + "top of bitmap should be inked (got alpha {top_alpha})" + ); + assert!( + bot_alpha == 0, + "bottom of bitmap should be empty (got alpha {bot_alpha})" + ); + assert!(!r.is_color, "glyf path produces an alpha mask"); + } + + #[test] + fn rasterize_mono_rejects_zero_pixel_size() { + let bytes = glyf_top_strip(100, 25); + assert!(rasterize_mono(&bytes, 100, 0).is_none()); + assert!(rasterize_mono(&bytes, 0, 16).is_none()); + } + + #[test] + fn project_p3_is_perpendicular_to_p0p2_axis_through_p0() { + // For any well-formed input, (P3 - P0) must be perpendicular + // to (P2 - P0). This is the defining property of the + // projection — skrifa, FreeType, and nanoemoji all document + // it as the `P0-P3 ⟂ P0-P2` constraint. + let cases = [ + ((0.0, 0.0), (10.0, 5.0), (20.0, 0.0)), + ((100.0, 100.0), (150.0, 200.0), (200.0, 100.0)), + ((0.0, 0.0), (3.0, 4.0), (5.0, 0.0)), + ((-50.0, 25.0), (0.0, 75.0), (50.0, 25.0)), + ]; + for (p0, p1, p2) in cases { + let (p3x, p3y) = project_p3(p0, p1, p2).unwrap(); + let p0p3 = (p3x - p0.0, p3y - p0.1); + let p0p2 = (p2.0 - p0.0, p2.1 - p0.1); + let d = dot(p0p3, p0p2); + assert!( + d.abs() < 1e-3, + "P0P3 · P0P2 = {d} for p0={p0:?} p1={p1:?} p2={p2:?}", + ); + } + } + + #[test] + fn project_p3_matches_skrifa_formulation() { + // Cross-check: P3 = P0 + project(P1-P0, perp(P2-P0)) should + // give the same result as our formulation. Skrifa computes + // this way; we compute P1 - t*(P2-P0). Both land on the same + // point mathematically. + let p0 = (10.0, 20.0); + let p1 = (50.0, 80.0); + let p2 = (100.0, 20.0); + + // Skrifa-style: project (P1-P0) onto perpendicular of (P2-P0). + let perp_x = p2.1 - p0.1; // (dy, -dx) rotation of P0→P2 + let perp_y = -(p2.0 - p0.0); + let b = (p1.0 - p0.0, p1.1 - p0.1); + let perp_len_sq = perp_x * perp_x + perp_y * perp_y; + let k = (b.0 * perp_x + b.1 * perp_y) / perp_len_sq; + let skrifa_p3 = (p0.0 + k * perp_x, p0.1 + k * perp_y); + + let (our_p3x, our_p3y) = project_p3(p0, p1, p2).unwrap(); + assert!((our_p3x - skrifa_p3.0).abs() < 1e-3); + assert!((our_p3y - skrifa_p3.1).abs() < 1e-3); + } + + #[test] + fn project_p3_rejects_degenerate_axis() { + // P0 == P2 means the color line has no direction. Must return + // None so the gradient shader falls back to solid colour. + assert!(project_p3((10.0, 20.0), (50.0, 50.0), (10.0, 20.0)).is_none()); + // Near-coincident (within epsilon) also rejected. + assert!( + project_p3((10.0, 20.0), (50.0, 50.0), (10.0 + 1e-4, 20.0 + 1e-4)).is_none() + ); + } + + #[test] + fn project_p3_p1_already_on_perpendicular_returns_p1() { + // If P1 is already on the perpendicular through P0 (i.e. its + // projection onto P0→P2 is at P0 itself), P3 should equal P1 + // exactly. + let p0 = (0.0, 0.0); + let p2 = (10.0, 0.0); + let p1 = (0.0, 5.0); // perpendicular to x-axis at origin + let (p3x, p3y) = project_p3(p0, p1, p2).unwrap(); + assert!((p3x - p1.0).abs() < 1e-6); + assert!((p3y - p1.1).abs() < 1e-6); + } + + #[test] + fn glyf_bbox_reads_signed_bbox() { + // numContours=1 (0x0001), x_min=-100, y_min=-200, x_max=300, y_max=700. + let bytes = [ + 0x00, 0x01, // numContours + 0xFF, 0x9C, // -100 + 0xFF, 0x38, // -200 + 0x01, 0x2C, // 300 + 0x02, 0xBC, // 700 + ]; + assert_eq!(glyf_bbox(&bytes), Some((-100, -200, 300, 700))); + } + + #[test] + fn glyf_bbox_rejects_short_input() { + assert_eq!(glyf_bbox(&[]), None); + assert_eq!(glyf_bbox(&[0; 9]), None); + } + + /// Build a minimal COLR v1 header + BaseGlyphList payload. + /// `base_glyph_ids` becomes the list of GlyphIDs written as + /// BaseGlyphPaintRecord entries, in order. + fn build_colr_v1(base_glyph_ids: &[u16]) -> Vec { + let mut out = Vec::new(); + // Header: version=1, num_v0=0, v0_off=0, layer_off=0, num_layers=0. + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + // base_glyph_list_offset — points right after the v1 header + // (32 bytes total: 14 v0 + 4 (v1_base) + 4 (v1_layer) + + // 4 (v1_clip) + 4 (varindex) + 4 (variationstore)). + let list_off: u32 = 34; + out.extend_from_slice(&list_off.to_be_bytes()); + // layer_list_offset, clip_list_offset, var_index_map_offset, + // item_variation_store_offset — all 0, unused. + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + assert_eq!(out.len(), list_off as usize); + // BaseGlyphList: num_records: u32, then (u16 gid, u32 paint_off). + out.extend_from_slice(&(base_glyph_ids.len() as u32).to_be_bytes()); + for &gid in base_glyph_ids { + out.extend_from_slice(&gid.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + } + out + } + + #[test] + fn first_base_glyph_id_picks_first_non_empty() { + // GID 0 is empty (.notdef), GID 1 has outline bytes — the + // subsetted-Nabla case. Must return 1, not 0. + let colr = build_colr_v1(&[0, 1]); + let glyphs: Vec> = vec![ + vec![], // GID 0: empty + vec![0xA, 0xB, 0xC], // GID 1: has bytes + ]; + assert_eq!(first_base_glyph_id(&colr, &glyphs), Some(1)); + } + + #[test] + fn first_base_glyph_id_honours_record_order() { + // All GIDs non-empty → returns the first one. + let colr = build_colr_v1(&[3, 1, 7]); + let glyphs: Vec> = vec![vec![1]; 10]; // GID 0..9 all non-empty + assert_eq!(first_base_glyph_id(&colr, &glyphs), Some(3)); + } + + #[test] + fn first_base_glyph_id_falls_back_to_first_record_when_all_empty() { + // Every record points at an empty outline (pathological case + // where the subsetter kept placeholders only). Return the + // first record's GID so the caller's bbox read bails cleanly + // rather than panicking on an `expect_some`. + let colr = build_colr_v1(&[5, 10]); + let glyphs: Vec> = vec![vec![]; 20]; + assert_eq!(first_base_glyph_id(&colr, &glyphs), Some(5)); + } + + #[test] + fn first_base_glyph_id_handles_empty_colr_table() { + // < 8 bytes: nothing to parse. + assert_eq!(first_base_glyph_id(&[], &[]), None); + assert_eq!(first_base_glyph_id(&[0, 0, 0, 0, 0, 0, 0, 0], &[]), None); + } + + #[test] + fn composite_mode_to_blend_covers_every_variant() { + // Every CompositeMode variant from ttf-parser's COLR spec + // (§Format 32 Paint​Composite) must map to some tiny-skia + // BlendMode. Exhaustive enum match catches a missing arm at + // compile time, but this test also guarantees the common + // `SourceOver` → `SourceOver` pairing — the one the layer + // stack falls back on when nothing special is in play. + use CompositeMode::*; + assert_eq!(composite_mode_to_blend(SourceOver), BlendMode::SourceOver); + assert_eq!(composite_mode_to_blend(Clear), BlendMode::Clear); + assert_eq!(composite_mode_to_blend(Xor), BlendMode::Xor); + assert_eq!(composite_mode_to_blend(Plus), BlendMode::Plus); + assert_eq!(composite_mode_to_blend(Multiply), BlendMode::Multiply); + assert_eq!(composite_mode_to_blend(Luminosity), BlendMode::Luminosity); + } + + #[test] + fn extend_to_spread_maps_all_three_modes() { + assert_eq!(extend_to_spread(GradientExtend::Pad), SpreadMode::Pad); + assert_eq!(extend_to_spread(GradientExtend::Repeat), SpreadMode::Repeat); + assert_eq!( + extend_to_spread(GradientExtend::Reflect), + SpreadMode::Reflect + ); + } + + #[test] + fn intersect_masks_multiplies_alpha_channels() { + let mut dst = Mask::new(2, 2).unwrap(); + let mut src = Mask::new(2, 2).unwrap(); + // Manually set the alpha bytes: dst = 255,128,64,0; src = 128,255,128,255. + dst.data_mut().copy_from_slice(&[255, 128, 64, 0]); + src.data_mut().copy_from_slice(&[128, 255, 128, 255]); + + intersect_masks(&mut dst, &src); + + // (255 * 128) / 255 = 128 + // (128 * 255) / 255 = 128 + // (64 * 128) / 255 = 32 (integer division) + // (0 * 255) / 255 = 0 + assert_eq!(dst.data(), &[128, 128, 32, 0]); + } + + #[test] + fn intersect_masks_ignores_mismatched_sizes() { + // Precondition: we only call intersect_masks on masks with + // the same dimensions. If that ever fails, we leave dst as-is + // rather than panicking. + let mut dst = Mask::new(2, 2).unwrap(); + dst.data_mut().copy_from_slice(&[0x55; 4]); + let src = Mask::new(4, 2).unwrap(); + intersect_masks(&mut dst, &src); + assert_eq!(dst.data(), &[0x55; 4]); + } +} diff --git a/sugarloaf/src/renderer/image_cache/mod.rs b/sugarloaf/src/renderer/image_cache/mod.rs index ba98ae15..9529d184 100644 --- a/sugarloaf/src/renderer/image_cache/mod.rs +++ b/sugarloaf/src/renderer/image_cache/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod atlas; mod cache; +pub mod colr_raster; use std::sync::Arc; diff --git a/sugarloaf/src/text.rs b/sugarloaf/src/text.rs index 977fc3d2..20104fb4 100644 --- a/sugarloaf/src/text.rs +++ b/sugarloaf/src/text.rs @@ -373,12 +373,13 @@ impl Text { }; ss.font_attrs = Attributes::new(Stretch::NORMAL, weight, fstyle); #[cfg(target_os = "macos")] - let resolved = self.font_library.resolve_font_for_char(first_ch, &ss); + let resolved = + self.font_library.resolve_font_for_char(first_ch, &ss, None); #[cfg(not(target_os = "macos"))] let resolved = { let lib = self.font_library.inner.read(); - lib.find_best_font_match(first_ch, &ss) + lib.find_best_font_match(first_ch, &ss, None) .unwrap_or((0, false)) }; let v = (resolved.0 as u32, resolved.1); -- 2.51.2