From 5d2787579798df840b20e587002b0255e22a83ca Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sat, 7 Feb 2026 07:56:03 +1030 Subject: [PATCH 01/15] . --- Cargo.lock | 267 +++++++++++++++++- Cargo.toml | 4 + crates/anyrender_serialize/Cargo.toml | 4 + crates/anyrender_serialize/src/lib.rs | 207 +++++++++++--- crates/anyrender_serialize/tests/serialize.rs | 37 ++- 5 files changed, 458 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7c4c0a..ca8335d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -88,6 +103,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyrender" version = "0.7.0" @@ -104,11 +169,15 @@ version = "0.1.0" dependencies = [ "anyrender", "image", + "klippa", "kurbo", "peniko", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations)", "serde", "serde_json", "sha2", + "ttf2woff2", + "wuff", "zip", ] @@ -354,6 +423,27 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -397,6 +487,12 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -500,6 +596,46 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "cocoa" version = "0.25.0" @@ -567,6 +703,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "com" version = "0.6.0" @@ -1009,6 +1151,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1021,6 +1169,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" + [[package]] name = "font-types" version = "0.10.0" @@ -1036,6 +1190,14 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.11.0" +source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -1380,6 +1542,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -1471,6 +1635,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1545,6 +1715,20 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "klippa" +version = "0.1.0" +source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "clap", + "fnv", + "hashbrown 0.15.5", + "regex", + "skrifa 0.40.0 (git+https://github.com/googlefonts/fontations)", + "thiserror 1.0.69", + "write-fonts", +] + [[package]] name = "kurbo" version = "0.13.0" @@ -2173,6 +2357,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.48" @@ -2516,7 +2706,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" dependencies = [ "bytemuck", - "font-types 0.11.0", + "font-types 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "bytemuck", + "font-types 0.11.0 (git+https://github.com/googlefonts/fontations)", ] [[package]] @@ -2844,7 +3043,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.37.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations)", ] [[package]] @@ -2970,6 +3178,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -3206,6 +3420,18 @@ dependencies = [ "core_maths", ] +[[package]] +name = "ttf2woff2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8ef4bdeee0ac1cec411193a14bfe665098d9409856da6aedb5177b11eb8d052" +dependencies = [ + "brotli", + "byteorder", + "clap", + "thiserror 2.0.17", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3314,6 +3540,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vello" version = "0.7.0" @@ -3325,7 +3557,7 @@ dependencies = [ "log", "peniko", "png 0.17.16", - "skrifa", + "skrifa 0.40.0 (registry+https://github.com/rust-lang/crates.io-index)", "static_assertions", "thiserror 2.0.17", "vello_encoding", @@ -3345,7 +3577,7 @@ dependencies = [ "log", "peniko", "png 0.17.16", - "skrifa", + "skrifa 0.40.0 (registry+https://github.com/rust-lang/crates.io-index)", "smallvec", ] @@ -3373,7 +3605,7 @@ dependencies = [ "bytemuck", "guillotiere", "peniko", - "skrifa", + "skrifa 0.40.0 (registry+https://github.com/rust-lang/crates.io-index)", "smallvec", ] @@ -4344,6 +4576,31 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "write-fonts" +version = "0.45.0" +source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "font-types 0.11.0 (git+https://github.com/googlefonts/fontations)", + "indexmap", + "kurbo", + "log", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations)", +] + +[[package]] +name = "wuff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088845d3772b9624d010137410e44bbdbf60a13ecf39338b7617723c29eb4afd" +dependencies = [ + "arrayvec", + "brotli-decompressor", + "bytes", + "flate2", + "font-types 0.9.0", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index 8a67b5c..7f60de3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ serde = "1.0.228" serde_json = "1.0" zip = { version = "2.1", default-features = false, features = ["deflate"] } sha2 = "0.10" +klippa = { git = "https://github.com/googlefonts/fontations" } +read-fonts = { git = "https://github.com/googlefonts/fontations" } +ttf2woff2 = "0.11" +wuff = "0.2" # Linebender color = "0.3" diff --git a/crates/anyrender_serialize/Cargo.toml b/crates/anyrender_serialize/Cargo.toml index 9a3df8a..d1d00c1 100644 --- a/crates/anyrender_serialize/Cargo.toml +++ b/crates/anyrender_serialize/Cargo.toml @@ -17,6 +17,10 @@ serde_json = { workspace = true } zip = { workspace = true } sha2 = { workspace = true } image = { workspace = true, features = ["png"] } +klippa = { workspace = true } +read-fonts = { workspace = true } +ttf2woff2 = { workspace = true } +wuff = { workspace = true } [dev-dependencies] kurbo = { workspace = true } diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index a9778f5..94b2607 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -7,13 +7,17 @@ //! - `resources.json` - Metadata mapping resource files to IDs //! - `draw_commands.json` - Serialized draw commands referencing resources by ID //! - `images/.png` - Image files (PNG format) -//! - `fonts/.ttf` - Font data files (TTF format) +//! - `fonts/.woff2` - Font data files (WOFF2 format, subsetted) -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::{Read, Seek, Write}; use image::{ImageBuffer, ImageEncoder, RgbaImage}; +use klippa::{Plan, SubsetFlags}; use peniko::{Blob, Brush, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; +use read_fonts::FontRef; +use read_fonts::collections::int_set::IntSet; +use read_fonts::types::GlyphId; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zip::write::SimpleFileOptions; @@ -49,8 +53,13 @@ pub struct FontResourceId { pub struct SceneArchive { pub manifest: ResourceManifest, pub commands: Vec, - pub fonts: Vec>, + pub fonts: Vec, pub images: Vec, + /// Cached WOFF2-encoded font data (parallel to `fonts`). + /// Populated during `from_scene()` and `deserialize()` so that + /// `serialize()` can write it directly and the manifest hash stays + /// consistent. + font_woff2: Vec>, } /// The resources manifest stored in the archive. @@ -66,7 +75,7 @@ pub struct ResourceManifest { impl ResourceManifest { /// Current archive format version. Bump this when the format changes. - pub const CURRENT_VERSION: u32 = 1; + pub const CURRENT_VERSION: u32 = 2; pub fn new(tolerance: f64) -> Self { Self { @@ -94,12 +103,14 @@ pub struct ImageMetadata { /// Metadata for a font resource. /// /// The font resource represents the raw font file data (which may be a font -/// collection containing multiple faces). The collection index is stored in -/// the drawing commands. +/// collection containing multiple faces). After subsetting, TTC fonts are +/// extracted to standalone fonts (index 0). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FontMetadata { #[serde(flatten)] pub entry: ResourceEntry, + /// Font collection index (0 for standalone fonts after TTC extraction). + pub index: u32, } /// Metadata for a resource in the archive. @@ -125,12 +136,16 @@ pub enum ResourceKind { /// Collects and deduplicates resources from a scene. struct ResourceCollector { - /// Maps Blob ID to ResourceId for fonts - font_id_map: HashMap, + /// Maps (Blob ID, font index) to ResourceId for fonts. + /// Keyed by both blob and index so that different faces from the same TTC + /// are treated as separate resources (each will be subsetted independently). + font_id_map: HashMap<(u64, u32), ResourceId>, /// Maps Blob ID to ResourceId for images image_id_map: HashMap, - /// Collected font file blobs - fonts: Vec>, + /// Collected fonts + fonts: Vec, + /// Glyph IDs used for each font resource (parallel to `fonts`) + font_glyph_ids: Vec>, /// Collected images images: Vec, } @@ -141,23 +156,33 @@ impl ResourceCollector { font_id_map: HashMap::new(), image_id_map: HashMap::new(), fonts: Vec::new(), + font_glyph_ids: Vec::new(), images: Vec::new(), } } /// Register a font and return its [`ResourceId`]. fn register_font(&mut self, font: &FontData) -> ResourceId { - let blob_id = font.data.id(); - if let Some(&id) = self.font_id_map.get(&blob_id) { + let key = (font.data.id(), font.index); + if let Some(&id) = self.font_id_map.get(&key) { return id; } let id = ResourceId(self.fonts.len()); - self.font_id_map.insert(blob_id, id); - self.fonts.push(font.data.clone()); + self.font_id_map.insert(key, id); + self.fonts.push(font.clone()); + self.font_glyph_ids.push(HashSet::new()); id } + /// Record glyph IDs used for a font resource. + fn register_glyphs(&mut self, font_id: ResourceId, glyphs: &[anyrender::Glyph]) { + let glyph_set = &mut self.font_glyph_ids[font_id.0]; + for glyph in glyphs { + glyph_set.insert(glyph.id as u16); + } + } + /// Register an image and return its [`ResourceId`]. fn register_image(&mut self, image: &ImageData) -> ResourceId { let blob_id = image.data.id(); @@ -210,6 +235,7 @@ impl ResourceCollector { }), RenderCommand::GlyphRun(glyph_run) => { let resource_id = self.register_font(&glyph_run.font_data); + self.register_glyphs(resource_id, &glyph_run.glyphs); let brush = self.convert_brush(&glyph_run.brush); SerializableRenderCommand::GlyphRun(GlyphRunCommand { font_data: FontResourceId { @@ -236,17 +262,17 @@ impl ResourceCollector { /// Reconstructs resources from deserialized data. struct ResourceReconstructor { - font_blobs: Vec>, + fonts: Vec, images: Vec, } impl ResourceReconstructor { - fn new(font_blobs: Vec>, images: Vec) -> Self { - Self { font_blobs, images } + fn new(fonts: Vec, images: Vec) -> Self { + Self { fonts, images } } - fn get_font_blob(&self, id: ResourceId) -> Result<&Blob, ArchiveError> { - self.font_blobs + fn get_font(&self, id: ResourceId) -> Result<&FontData, ArchiveError> { + self.fonts .get(id.0) .ok_or(ArchiveError::ResourceNotFound(id)) } @@ -298,10 +324,7 @@ impl ResourceReconstructor { shape: fill.shape.clone(), }), SerializableRenderCommand::GlyphRun(glyph_run) => { - let font_data = FontData::new( - self.get_font_blob(glyph_run.font_data.resource_id)?.clone(), - glyph_run.font_data.index, - ); + let font_data = self.get_font(glyph_run.font_data.resource_id)?.clone(); let brush = self.convert_brush(&glyph_run.brush)?; RenderCommand::GlyphRun(GlyphRunCommand { font_data, @@ -393,18 +416,75 @@ fn convert_to_rgba(image: &ImageData) -> Result, ArchiveError> { } } +/// Decode a WOFF2 file back to OpenType/TTF using the `wuff` crate. +fn decode_woff2_to_ttf(woff2_data: &[u8]) -> Result, ArchiveError> { + wuff::decompress_woff2(woff2_data) + .map_err(|e| ArchiveError::FontProcessing(format!("WOFF2 decoding failed: {e}"))) +} + impl SceneArchive { /// Create a new SceneArchive from a recorded Scene. + /// + /// Font processing (powered by [klippa](https://github.com/googlefonts/fontations/tree/main/klippa)): + /// - TTC files are extracted to standalone fonts for each face used + /// - Fonts are subsetted to include only the glyphs referenced in the scene + /// - Original glyph IDs are preserved (RETAIN_GIDS) — unused slots become empty + /// - Fonts are stored as WOFF2 for compression pub fn from_scene(scene: &Scene) -> Result { let mut manifest = ResourceManifest::new(scene.tolerance); let mut collector = ResourceCollector::new(); - let commands: Vec<_> = scene + let mut commands: Vec<_> = scene .commands .iter() .map(|cmd| collector.convert_command(cmd)) .collect(); + // --- Font subsetting (using klippa from fontations) --- + // For each collected font, subset to only the glyphs used and extract + // from TTC to standalone TTF. We use RETAIN_GIDS so that original glyph + // IDs are preserved (unused slots become empty) — this avoids having to + // remap glyph IDs in draw commands. + let mut processed_fonts: Vec = Vec::with_capacity(collector.fonts.len()); + + for (idx, font) in collector.fonts.iter().enumerate() { + let glyph_ids = &collector.font_glyph_ids[idx]; + + let font_ref = FontRef::from_index(font.data.data(), font.index) + .map_err(|e| ArchiveError::FontProcessing(format!("Failed to parse font: {e}")))?; + + let mut input_gids: IntSet = IntSet::empty(); + for &gid in glyph_ids { + input_gids.insert(GlyphId::new(gid as u32)); + } + + let plan = Plan::new( + &input_gids, + &IntSet::empty(), // no unicode input + &font_ref, + SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, // keep original glyph IDs + &IntSet::empty(), // no tables to drop + &IntSet::empty(), // all scripts + &IntSet::empty(), // default layout features + &IntSet::empty(), // no name ID filter + &IntSet::empty(), // no name language filter + ); + + let subset_data = klippa::subset_font(&font_ref, &plan).map_err(|e| { + ArchiveError::FontProcessing(format!("Font subsetting failed: {e}")) + })?; + + processed_fonts.push(FontData::new(Blob::from(subset_data), 0)); + } + + // Update font index in commands (fonts are now standalone after TTC extraction). + // Glyph IDs are preserved thanks to RETAIN_GIDS. + for cmd in &mut commands { + if let SerializableRenderCommand::GlyphRun(glyph_run) = cmd { + glyph_run.font_data.index = 0; + } + } + // Normalize all images to RGBA8 let images: Vec = collector .images @@ -443,27 +523,37 @@ impl SceneArchive { }); } - // Add font metadata - for (idx, blob) in collector.fonts.iter().enumerate() { - let data = blob.data(); - let hash = sha256_hex(data); - let path = format!("fonts/{}.ttf", hash); + // WOFF2-encode each font and build metadata. + // We hash the WOFF2 bytes (not the TTF bytes) because the WOFF2 + // encode→decode round-trip does not preserve the exact byte layout + // of the original sfnt. Hashing the stored format ensures the hash + // can be verified on deserialization. + let mut font_woff2 = Vec::with_capacity(processed_fonts.len()); + for (idx, font) in processed_fonts.iter().enumerate() { + let ttf_data = font.data.data(); + let woff2_data = ttf2woff2::encode(ttf_data, ttf2woff2::BrotliQuality::default()) + .map_err(|e| ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")))?; + let hash = sha256_hex(&woff2_data); + let path = format!("fonts/{}.woff2", hash); manifest.fonts.push(FontMetadata { entry: ResourceEntry { id: ResourceId(idx), kind: ResourceKind::Font, - size: data.len(), + size: ttf_data.len(), sha256_hash: hash, path, }, + index: 0, // Standalone after TTC extraction + subsetting }); + font_woff2.push(woff2_data); } Ok(Self { manifest, commands, - fonts: collector.fonts, + fonts: processed_fonts, + font_woff2, images, }) } @@ -528,11 +618,11 @@ impl SceneArchive { zip.write_all(&png_data)?; } - // Write font files - for (idx, blob) in self.fonts.iter().enumerate() { + // Write font files as WOFF2 + for (idx, woff2_data) in self.font_woff2.iter().enumerate() { let path = &self.manifest.fonts[idx].entry.path; zip.start_file(path, options)?; - zip.write_all(blob.data())?; + zip.write_all(woff2_data)?; } zip.finish()?; @@ -590,29 +680,50 @@ impl SceneArchive { }); } - // Read fonts + // Read fonts (v1: raw TTF, v2+: WOFF2 compressed) let mut fonts = Vec::with_capacity(manifest.fonts.len()); + let mut font_woff2 = Vec::with_capacity(manifest.fonts.len()); for meta in &manifest.fonts { let mut file = zip.by_name(&meta.entry.path)?; - let mut data = Vec::with_capacity(meta.entry.size); - file.read_to_end(&mut data)?; - - // Verify hash - let hash = sha256_hex(&data); - if hash != meta.entry.sha256_hash { - return Err(ArchiveError::InvalidFormat(format!( - "Hash mismatch for {}: expected {}, got {}", - meta.entry.path, meta.entry.sha256_hash, hash - ))); + let mut raw_data = Vec::new(); + file.read_to_end(&mut raw_data)?; + + if manifest.version >= 2 { + // v2+: data is WOFF2 — verify hash against stored WOFF2 bytes + let hash = sha256_hex(&raw_data); + if hash != meta.entry.sha256_hash { + return Err(ArchiveError::InvalidFormat(format!( + "Hash mismatch for {}: expected {}, got {}", + meta.entry.path, meta.entry.sha256_hash, hash + ))); + } + let ttf_data = decode_woff2_to_ttf(&raw_data)?; + fonts.push(FontData::new(Blob::from(ttf_data), meta.index)); + font_woff2.push(raw_data); + } else { + // v1: data is raw TTF — verify hash against TTF bytes + let hash = sha256_hex(&raw_data); + if hash != meta.entry.sha256_hash { + return Err(ArchiveError::InvalidFormat(format!( + "Hash mismatch for {}: expected {}, got {}", + meta.entry.path, meta.entry.sha256_hash, hash + ))); + } + // Re-encode to WOFF2 for the cache so serialize() works + let woff2_data = ttf2woff2::encode(&raw_data, ttf2woff2::BrotliQuality::default()) + .map_err(|e| { + ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")) + })?; + font_woff2.push(woff2_data); + fonts.push(FontData::new(Blob::from(raw_data), meta.index)); } - - fonts.push(Blob::from(data)); } Ok(Self { manifest, commands, fonts, + font_woff2, images, }) } @@ -624,6 +735,7 @@ pub enum ArchiveError { Json(serde_json::Error), Zip(zip::result::ZipError), Image(image::ImageError), + FontProcessing(String), InvalidFormat(String), ResourceNotFound(ResourceId), UnsupportedVersion(u32), @@ -636,6 +748,7 @@ impl std::fmt::Display for ArchiveError { ArchiveError::Json(e) => write!(f, "JSON error: {}", e), ArchiveError::Zip(e) => write!(f, "Zip error: {}", e), ArchiveError::Image(e) => write!(f, "Image error: {}", e), + ArchiveError::FontProcessing(msg) => write!(f, "Font processing error: {}", msg), ArchiveError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg), ArchiveError::ResourceNotFound(id) => write!(f, "Resource not found: {:?}", id), ArchiveError::UnsupportedVersion(v) => write!(f, "Unsupported version: {}", v), diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index 29ab4ad..950a164 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -203,7 +203,7 @@ fn test_multiple_different_images() { #[test] fn test_glyph_run_roundtrip() { let font = roboto_font(); - let font_bytes = font.data.data().to_vec(); + let original_font_size = font.data.data().len(); let mut scene = Scene::new(); let glyphs = [ @@ -249,10 +249,22 @@ fn test_glyph_run_roundtrip() { // Verify font metadata assert_eq!(archive.manifest.fonts.len(), 1); - assert_eq!(archive.manifest.fonts[0].entry.size, font_bytes.len()); + assert_eq!(archive.manifest.fonts[0].index, 0); // Subsetted to standalone + assert!( + archive.manifest.fonts[0].entry.size < original_font_size, + "Subsetted font ({} bytes) should be smaller than original ({} bytes)", + archive.manifest.fonts[0].entry.size, + original_font_size + ); + + // Verify font is subsetted (smaller than original) + assert!(archive.fonts[0].data.data().len() < original_font_size); - // Verify font bytes - assert_eq!(archive.fonts[0].data(), font_bytes.as_slice()); + // Verify the WOFF2 file path + assert!( + archive.manifest.fonts[0].entry.path.ends_with(".woff2"), + "Font path should use .woff2 extension" + ); // Verify the scene roundtrip let restored = archive.to_scene().unwrap(); @@ -260,14 +272,21 @@ fn test_glyph_run_roundtrip() { match &restored.commands[0] { RenderCommand::GlyphRun(glyph_run) => { - assert_eq!(glyph_run.font_data.data.data(), font.data.data()); - assert_eq!(glyph_run.font_data.index, font.index); assert_eq!(glyph_run.font_size, font_size); assert_eq!(glyph_run.hint, hint); assert_eq!(glyph_run.brush_alpha, brush_alpha); assert_eq!(glyph_run.transform, transform); assert_eq!(glyph_run.glyph_transform, glyph_transform); - assert_eq!(glyph_run.glyphs, glyphs); + assert_eq!(glyph_run.font_data.index, 0); // Standalone after subsetting + assert_eq!(glyph_run.glyphs.len(), 3); + // Glyph positions are preserved + assert_eq!(glyph_run.glyphs[0].x, 0.0); + assert_eq!(glyph_run.glyphs[1].x, 10.0); + assert_eq!(glyph_run.glyphs[2].x, 20.0); + // Glyph IDs are preserved (RETAIN_GIDS keeps original IDs) + assert_eq!(glyph_run.glyphs[0].id, 43); + assert_eq!(glyph_run.glyphs[1].id, 72); + assert_eq!(glyph_run.glyphs[2].id, 79); } other => panic!("Expected GlyphRun command, got {other:?}"), } @@ -306,7 +325,7 @@ fn test_font_deduplication() { #[test] fn test_resource_manifest_version() { - assert_eq!(ResourceManifest::CURRENT_VERSION, 1); + assert_eq!(ResourceManifest::CURRENT_VERSION, 2); } #[test] @@ -330,7 +349,7 @@ fn test_archive_contains_expected_files() { .read_to_string(&mut resources_json) .unwrap(); let manifest: ResourceManifest = serde_json::from_str(&resources_json).unwrap(); - assert_eq!(manifest.version, 1); + assert_eq!(manifest.version, 2); assert!(manifest.images.is_empty()); assert!(manifest.fonts.is_empty()); From 9ea108442eaed10237600cd173e7f7e16de78d61 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 05:57:28 +1030 Subject: [PATCH 02/15] . --- crates/anyrender_serialize/src/lib.rs | 132 +++++++----------- crates/anyrender_serialize/tests/serialize.rs | 4 - 2 files changed, 48 insertions(+), 88 deletions(-) diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 94b2607..f71f414 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -53,13 +53,9 @@ pub struct FontResourceId { pub struct SceneArchive { pub manifest: ResourceManifest, pub commands: Vec, - pub fonts: Vec, + /// WOFF2-encoded font data (subsetted, one per font resource). + pub fonts: Vec>, pub images: Vec, - /// Cached WOFF2-encoded font data (parallel to `fonts`). - /// Populated during `from_scene()` and `deserialize()` so that - /// `serialize()` can write it directly and the manifest hash stays - /// consistent. - font_woff2: Vec>, } /// The resources manifest stored in the archive. @@ -101,16 +97,10 @@ pub struct ImageMetadata { } /// Metadata for a font resource. -/// -/// The font resource represents the raw font file data (which may be a font -/// collection containing multiple faces). After subsetting, TTC fonts are -/// extracted to standalone fonts (index 0). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FontMetadata { #[serde(flatten)] pub entry: ResourceEntry, - /// Font collection index (0 for standalone fonts after TTC extraction). - pub index: u32, } /// Metadata for a resource in the archive. @@ -137,15 +127,15 @@ pub enum ResourceKind { /// Collects and deduplicates resources from a scene. struct ResourceCollector { /// Maps (Blob ID, font index) to ResourceId for fonts. - /// Keyed by both blob and index so that different faces from the same TTC - /// are treated as separate resources (each will be subsetted independently). + /// Faces in a collection are extracted into standalone fonts, so this mapping enables us + /// to identify the correct font resource for a given face in a collection. font_id_map: HashMap<(u64, u32), ResourceId>, /// Maps Blob ID to ResourceId for images image_id_map: HashMap, /// Collected fonts fonts: Vec, /// Glyph IDs used for each font resource (parallel to `fonts`) - font_glyph_ids: Vec>, + font_glyph_ids: Vec>, /// Collected images images: Vec, } @@ -179,7 +169,7 @@ impl ResourceCollector { fn register_glyphs(&mut self, font_id: ResourceId, glyphs: &[anyrender::Glyph]) { let glyph_set = &mut self.font_glyph_ids[font_id.0]; for glyph in glyphs { - glyph_set.insert(glyph.id as u16); + glyph_set.insert(glyph.id); } } @@ -240,7 +230,7 @@ impl ResourceCollector { SerializableRenderCommand::GlyphRun(GlyphRunCommand { font_data: FontResourceId { resource_id, - index: glyph_run.font_data.index, + index: 0, // All faces are extracted into standalone fonts during archival. }, font_size: glyph_run.font_size, hint: glyph_run.hint, @@ -416,12 +406,6 @@ fn convert_to_rgba(image: &ImageData) -> Result, ArchiveError> { } } -/// Decode a WOFF2 file back to OpenType/TTF using the `wuff` crate. -fn decode_woff2_to_ttf(woff2_data: &[u8]) -> Result, ArchiveError> { - wuff::decompress_woff2(woff2_data) - .map_err(|e| ArchiveError::FontProcessing(format!("WOFF2 decoding failed: {e}"))) -} - impl SceneArchive { /// Create a new SceneArchive from a recorded Scene. /// @@ -434,7 +418,7 @@ impl SceneArchive { let mut manifest = ResourceManifest::new(scene.tolerance); let mut collector = ResourceCollector::new(); - let mut commands: Vec<_> = scene + let commands: Vec<_> = scene .commands .iter() .map(|cmd| collector.convert_command(cmd)) @@ -455,19 +439,19 @@ impl SceneArchive { let mut input_gids: IntSet = IntSet::empty(); for &gid in glyph_ids { - input_gids.insert(GlyphId::new(gid as u32)); + input_gids.insert(GlyphId::new(gid)); } let plan = Plan::new( &input_gids, - &IntSet::empty(), // no unicode input + &IntSet::empty(), &font_ref, SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, // keep original glyph IDs - &IntSet::empty(), // no tables to drop - &IntSet::empty(), // all scripts - &IntSet::empty(), // default layout features - &IntSet::empty(), // no name ID filter - &IntSet::empty(), // no name language filter + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), ); let subset_data = klippa::subset_font(&font_ref, &plan).map_err(|e| { @@ -477,14 +461,6 @@ impl SceneArchive { processed_fonts.push(FontData::new(Blob::from(subset_data), 0)); } - // Update font index in commands (fonts are now standalone after TTC extraction). - // Glyph IDs are preserved thanks to RETAIN_GIDS. - for cmd in &mut commands { - if let SerializableRenderCommand::GlyphRun(glyph_run) = cmd { - glyph_run.font_data.index = 0; - } - } - // Normalize all images to RGBA8 let images: Vec = collector .images @@ -524,11 +500,7 @@ impl SceneArchive { } // WOFF2-encode each font and build metadata. - // We hash the WOFF2 bytes (not the TTF bytes) because the WOFF2 - // encode→decode round-trip does not preserve the exact byte layout - // of the original sfnt. Hashing the stored format ensures the hash - // can be verified on deserialization. - let mut font_woff2 = Vec::with_capacity(processed_fonts.len()); + let mut fonts: Vec> = Vec::with_capacity(processed_fonts.len()); for (idx, font) in processed_fonts.iter().enumerate() { let ttf_data = font.data.data(); let woff2_data = ttf2woff2::encode(ttf_data, ttf2woff2::BrotliQuality::default()) @@ -544,16 +516,14 @@ impl SceneArchive { sha256_hash: hash, path, }, - index: 0, // Standalone after TTC extraction + subsetting }); - font_woff2.push(woff2_data); + fonts.push(Blob::from(woff2_data)); } Ok(Self { manifest, commands, - fonts: processed_fonts, - font_woff2, + fonts, images, }) } @@ -577,7 +547,23 @@ impl SceneArchive { }) .collect::, ArchiveError>>()?; - let reconstructor = ResourceReconstructor::new(self.fonts.clone(), images); + // Decompress WOFF2 fonts to TTF + let fonts_ttf: Vec = self + .fonts + .iter() + .map(|woff2| { + let ttf = wuff::decompress_woff2(woff2.data()).map_err(|e| { + ArchiveError::FontProcessing(format!("WOFF2 decoding failed: {e}")) + })?; + Ok(FontData::new( + Blob::from(ttf), + // Since we've extracted all faces into standalone fonts, the index is always 0. + 0, + )) + }) + .collect::, ArchiveError>>()?; + + let reconstructor = ResourceReconstructor::new(fonts_ttf, images); let commands: Result, _> = self .commands @@ -619,10 +605,10 @@ impl SceneArchive { } // Write font files as WOFF2 - for (idx, woff2_data) in self.font_woff2.iter().enumerate() { + for (idx, woff2_data) in self.fonts.iter().enumerate() { let path = &self.manifest.fonts[idx].entry.path; zip.start_file(path, options)?; - zip.write_all(woff2_data)?; + zip.write_all(woff2_data.data())?; } zip.finish()?; @@ -642,7 +628,7 @@ impl SceneArchive { }; // Check version - if manifest.version > ResourceManifest::CURRENT_VERSION { + if manifest.version != ResourceManifest::CURRENT_VERSION { return Err(ArchiveError::UnsupportedVersion(manifest.version)); } @@ -680,50 +666,28 @@ impl SceneArchive { }); } - // Read fonts (v1: raw TTF, v2+: WOFF2 compressed) - let mut fonts = Vec::with_capacity(manifest.fonts.len()); - let mut font_woff2 = Vec::with_capacity(manifest.fonts.len()); + // Read fonts (WOFF2 compressed) + let mut fonts: Vec> = Vec::with_capacity(manifest.fonts.len()); for meta in &manifest.fonts { let mut file = zip.by_name(&meta.entry.path)?; let mut raw_data = Vec::new(); file.read_to_end(&mut raw_data)?; - if manifest.version >= 2 { - // v2+: data is WOFF2 — verify hash against stored WOFF2 bytes - let hash = sha256_hex(&raw_data); - if hash != meta.entry.sha256_hash { - return Err(ArchiveError::InvalidFormat(format!( - "Hash mismatch for {}: expected {}, got {}", - meta.entry.path, meta.entry.sha256_hash, hash - ))); - } - let ttf_data = decode_woff2_to_ttf(&raw_data)?; - fonts.push(FontData::new(Blob::from(ttf_data), meta.index)); - font_woff2.push(raw_data); - } else { - // v1: data is raw TTF — verify hash against TTF bytes - let hash = sha256_hex(&raw_data); - if hash != meta.entry.sha256_hash { - return Err(ArchiveError::InvalidFormat(format!( - "Hash mismatch for {}: expected {}, got {}", - meta.entry.path, meta.entry.sha256_hash, hash - ))); - } - // Re-encode to WOFF2 for the cache so serialize() works - let woff2_data = ttf2woff2::encode(&raw_data, ttf2woff2::BrotliQuality::default()) - .map_err(|e| { - ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")) - })?; - font_woff2.push(woff2_data); - fonts.push(FontData::new(Blob::from(raw_data), meta.index)); + // Verify hash + let hash = sha256_hex(&raw_data); + if hash != meta.entry.sha256_hash { + return Err(ArchiveError::InvalidFormat(format!( + "Hash mismatch for {}: expected {}, got {}", + meta.entry.path, meta.entry.sha256_hash, hash + ))); } + fonts.push(Blob::from(raw_data)); } Ok(Self { manifest, commands, fonts, - font_woff2, images, }) } diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index 950a164..5a61685 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -249,7 +249,6 @@ fn test_glyph_run_roundtrip() { // Verify font metadata assert_eq!(archive.manifest.fonts.len(), 1); - assert_eq!(archive.manifest.fonts[0].index, 0); // Subsetted to standalone assert!( archive.manifest.fonts[0].entry.size < original_font_size, "Subsetted font ({} bytes) should be smaller than original ({} bytes)", @@ -257,9 +256,6 @@ fn test_glyph_run_roundtrip() { original_font_size ); - // Verify font is subsetted (smaller than original) - assert!(archive.fonts[0].data.data().len() < original_font_size); - // Verify the WOFF2 file path assert!( archive.manifest.fonts[0].entry.path.ends_with(".woff2"), From c62f4369dc80b0a720a02131ff222219bc325c79 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 06:10:54 +1030 Subject: [PATCH 03/15] . --- Cargo.lock | 22 ++++++------ Cargo.toml | 4 +-- crates/anyrender_serialize/Cargo.toml | 2 ++ crates/anyrender_serialize/src/lib.rs | 17 ++++----- crates/anyrender_serialize/tests/serialize.rs | 35 ++++++++++++++++--- 5 files changed, 55 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca8335d..7fbaae4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,7 @@ dependencies = [ "klippa", "kurbo", "peniko", - "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations)", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", "serde", "serde_json", "sha2", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "font-types" version = "0.11.0" -source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" dependencies = [ "bytemuck", ] @@ -1718,13 +1718,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "klippa" version = "0.1.0" -source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" dependencies = [ "clap", "fnv", "hashbrown 0.15.5", "regex", - "skrifa 0.40.0 (git+https://github.com/googlefonts/fontations)", + "skrifa 0.40.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", "thiserror 1.0.69", "write-fonts", ] @@ -2712,10 +2712,10 @@ dependencies = [ [[package]] name = "read-fonts" version = "0.37.0" -source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" dependencies = [ "bytemuck", - "font-types 0.11.0 (git+https://github.com/googlefonts/fontations)", + "font-types 0.11.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", ] [[package]] @@ -3049,10 +3049,10 @@ dependencies = [ [[package]] name = "skrifa" version = "0.40.0" -source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" dependencies = [ "bytemuck", - "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations)", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", ] [[package]] @@ -4579,13 +4579,13 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "write-fonts" version = "0.45.0" -source = "git+https://github.com/googlefonts/fontations#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" dependencies = [ - "font-types 0.11.0 (git+https://github.com/googlefonts/fontations)", + "font-types 0.11.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", "indexmap", "kurbo", "log", - "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations)", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7f60de3..27f5786 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,8 +43,8 @@ serde = "1.0.228" serde_json = "1.0" zip = { version = "2.1", default-features = false, features = ["deflate"] } sha2 = "0.10" -klippa = { git = "https://github.com/googlefonts/fontations" } -read-fonts = { git = "https://github.com/googlefonts/fontations" } +klippa = { git = "https://github.com/googlefonts/fontations", rev = "41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" } +read-fonts = { git = "https://github.com/googlefonts/fontations", rev = "41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" } ttf2woff2 = "0.11" wuff = "0.2" diff --git a/crates/anyrender_serialize/Cargo.toml b/crates/anyrender_serialize/Cargo.toml index d1d00c1..de01168 100644 --- a/crates/anyrender_serialize/Cargo.toml +++ b/crates/anyrender_serialize/Cargo.toml @@ -25,5 +25,7 @@ wuff = { workspace = true } [dev-dependencies] kurbo = { workspace = true } peniko = { workspace = true } +read-fonts = { workspace = true } serde_json = { workspace = true } +wuff = { workspace = true } zip = { workspace = true } diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index f71f414..5a1b2ec 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -97,6 +97,9 @@ pub struct ImageMetadata { } /// Metadata for a font resource. +/// +/// Fonts are stored as WOFF2 in the archive. During archival, TTC fonts are +/// extracted to standalone fonts and subsetted to only the glyphs used. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FontMetadata { #[serde(flatten)] @@ -409,11 +412,11 @@ fn convert_to_rgba(image: &ImageData) -> Result, ArchiveError> { impl SceneArchive { /// Create a new SceneArchive from a recorded Scene. /// - /// Font processing (powered by [klippa](https://github.com/googlefonts/fontations/tree/main/klippa)): + /// Font processing: /// - TTC files are extracted to standalone fonts for each face used - /// - Fonts are subsetted to include only the glyphs referenced in the scene + /// - Fonts are subsetted to include only the glyphs referenced in the scene (using [`klippa`]`) /// - Original glyph IDs are preserved (RETAIN_GIDS) — unused slots become empty - /// - Fonts are stored as WOFF2 for compression + /// - Fonts are stored as WOFF2 pub fn from_scene(scene: &Scene) -> Result { let mut manifest = ResourceManifest::new(scene.tolerance); let mut collector = ResourceCollector::new(); @@ -424,11 +427,8 @@ impl SceneArchive { .map(|cmd| collector.convert_command(cmd)) .collect(); - // --- Font subsetting (using klippa from fontations) --- // For each collected font, subset to only the glyphs used and extract - // from TTC to standalone TTF. We use RETAIN_GIDS so that original glyph - // IDs are preserved (unused slots become empty) — this avoids having to - // remap glyph IDs in draw commands. + // from TTC to standalone TTF. let mut processed_fonts: Vec = Vec::with_capacity(collector.fonts.len()); for (idx, font) in collector.fonts.iter().enumerate() { @@ -446,7 +446,8 @@ impl SceneArchive { &input_gids, &IntSet::empty(), &font_ref, - SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, // keep original glyph IDs + // keep original glyph IDs (so we don't need to remap them in the draw commands) + SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, &IntSet::empty(), &IntSet::empty(), &IntSet::empty(), diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index 5a61685..060b5ef 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -12,6 +12,7 @@ use peniko::{ Blob, Brush, Color, Compose, Fill, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix, }; +use read_fonts::TableProvider; use zip::ZipArchive; #[test] @@ -257,10 +258,36 @@ fn test_glyph_run_roundtrip() { ); // Verify the WOFF2 file path - assert!( - archive.manifest.fonts[0].entry.path.ends_with(".woff2"), - "Font path should use .woff2 extension" - ); + assert!(archive.manifest.fonts[0].entry.path.ends_with(".woff2")); + + // Verify subsetting + { + let ttf_data = wuff::decompress_woff2(archive.fonts[0].data()).unwrap(); + let font_ref = read_fonts::FontRef::new(&ttf_data).unwrap(); + let loca = font_ref.loca(None).unwrap(); + let glyf = font_ref.glyf().unwrap(); + + // The used glyph IDs (43, 72, 79) should have outlines in the subsetted font + for &gid in &[43u32, 72, 79] { + let glyph = loca + .get_glyf(read_fonts::types::GlyphId::new(gid), &glyf) + .unwrap(); + assert!( + glyph.is_some(), + "Glyph {gid} should have an outline in the subsetted font" + ); + } + + // An unused glyph ID should be an empty slot (RETAIN_GIDS preserves IDs + // but removes outlines for glyphs not in the subset) + let unused_glyph = loca + .get_glyf(read_fonts::types::GlyphId::new(50), &glyf) + .unwrap(); + assert!( + unused_glyph.is_none(), + "Glyph 50 should be an empty slot in the subsetted font" + ); + } // Verify the scene roundtrip let restored = archive.to_scene().unwrap(); From 8ef7812ecb7a6fbaa05523af706a79b457615a62 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 06:29:31 +1030 Subject: [PATCH 04/15] . --- Cargo.lock | 142 ++++++++++++++++++++++++++ crates/anyrender_serialize/src/lib.rs | 7 +- examples/serialize/Cargo.toml | 1 + examples/serialize/src/main.rs | 102 +++++++++++++++++- 4 files changed, 248 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7fbaae4..c98bb8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1180,6 +1180,9 @@ name = "font-types" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "511e2c18a516c666d27867d2f9821f76e7d591f762e9fc41dd6cc5c90fe54b0b" +dependencies = [ + "bytemuck", +] [[package]] name = "font-types" @@ -1221,6 +1224,21 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "fontique" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bbc252c93499b6d3635d692f892a637db0dbb130ce9b32bf20b28e0dcc470b" +dependencies = [ + "bytemuck", + "hashbrown 0.16.1", + "icu_locale_core", + "linebender_resource_handle", + "memmap2", + "read-fonts 0.35.0", + "smallvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1526,6 +1544,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1591,6 +1622,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", +] + [[package]] name = "image" version = "0.25.8" @@ -1812,6 +1856,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "litrs" version = "1.0.0" @@ -2422,6 +2472,20 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parley" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada5338c3a9794af7342e6f765b6e78740db37378aced034d7bf72c96b94ed94" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.16.1", + "linebender_resource_handle", + "skrifa 0.37.0", + "swash", +] + [[package]] name = "paste" version = "1.0.15" @@ -2699,6 +2763,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.0", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -2968,6 +3043,7 @@ dependencies = [ "anyrender_vello_cpu", "image", "kurbo", + "parley", "peniko", ] @@ -3036,6 +3112,16 @@ dependencies = [ "skia-bindings", ] +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + [[package]] name = "skrifa" version = "0.40.0" @@ -3200,6 +3286,17 @@ dependencies = [ "siphasher", ] +[[package]] +name = "swash" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" +dependencies = [ + "skrifa 0.37.0", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "1.0.109" @@ -3329,6 +3426,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -4588,6 +4696,12 @@ dependencies = [ "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "wuff" version = "0.2.3" @@ -4680,6 +4794,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.27" @@ -4700,6 +4826,22 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "zerofrom", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 5a1b2ec..3175084 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -504,8 +504,11 @@ impl SceneArchive { let mut fonts: Vec> = Vec::with_capacity(processed_fonts.len()); for (idx, font) in processed_fonts.iter().enumerate() { let ttf_data = font.data.data(); - let woff2_data = ttf2woff2::encode(ttf_data, ttf2woff2::BrotliQuality::default()) - .map_err(|e| ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")))?; + let woff2_data = + ttf2woff2::encode_no_transform(ttf_data, ttf2woff2::BrotliQuality::default()) + .map_err(|e| { + ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")) + })?; let hash = sha256_hex(&woff2_data); let path = format!("fonts/{}.woff2", hash); diff --git a/examples/serialize/Cargo.toml b/examples/serialize/Cargo.toml index c436cc2..09af45c 100644 --- a/examples/serialize/Cargo.toml +++ b/examples/serialize/Cargo.toml @@ -12,3 +12,4 @@ image = { workspace = true, features = ["png"] } anyrender = { workspace = true, features = ["serde"] } anyrender_serialize = { workspace = true } anyrender_vello_cpu = { workspace = true } +parley = { version = "0.7", default-features = false, features = ["std"] } diff --git a/examples/serialize/src/main.rs b/examples/serialize/src/main.rs index 7f25b63..3c7724b 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -5,12 +5,16 @@ use std::io::BufWriter; use std::path::Path; use anyrender::recording::Scene; -use anyrender::{PaintScene, render_to_buffer}; +use anyrender::{Glyph, PaintScene, render_to_buffer}; use anyrender_serialize::SceneArchive; use anyrender_vello_cpu::VelloCpuImageRenderer; use image::{ImageBuffer, RgbaImage}; use kurbo::{Affine, Circle, Point, Rect, RoundedRect, Stroke}; -use peniko::{Blob, Color, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix}; +use parley::style::{FontFamily, FontStack}; +use parley::{Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, StyleProperty}; +use peniko::{ + Blob, Color, Fill, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix, +}; const WIDTH: u32 = 400; const HEIGHT: u32 = 300; @@ -91,6 +95,9 @@ fn create_demo_scene() -> Scene { &rounded_card, ); + // Text + draw_text_with_parley(&mut scene); + // Draw some circles using layers with blend modes scene.push_layer( Mix::Multiply, @@ -181,6 +188,97 @@ fn create_demo_scene() -> Scene { scene } +/// Lay out text with parley using the Roboto font and draw it onto the scene. +fn draw_text_with_parley(scene: &mut Scene) { + let mut font_cx = FontContext::new(); + let mut layout_cx = LayoutContext::new(); + + let font_blob = Blob::from(include_bytes!("../../../assets/fonts/roboto/Roboto.ttf").to_vec()); + font_cx.collection.register_fonts(font_blob.clone(), None); + + let title_text = "Hello, AnyRender!"; + let display_scale = 1.0; + let mut builder = layout_cx.ranged_builder(&mut font_cx, title_text, display_scale, true); + builder.push_default(StyleProperty::FontSize(18.0)); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named("Roboto".into()), + ))); + + // Title + { + let mut title_layout: Layout<()> = builder.build(title_text); + title_layout.break_all_lines(Some(140.0)); + title_layout.align(Some(140.0), Alignment::Start, AlignmentOptions::default()); + render_layout( + scene, + &title_layout, + &font_blob, + Affine::translate((32.0, 50.0)), + Color::from_rgb8(40, 40, 60), + ); + } + // Paragraph + { + let paragraph_text = + "Serialization roundtrip test: fonts are subsetted, compressed to WOFF2, and restored."; + let mut builder = + layout_cx.ranged_builder(&mut font_cx, paragraph_text, display_scale, true); + builder.push_default(StyleProperty::FontSize(13.0)); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named("Roboto".into()), + ))); + let mut para_layout: Layout<()> = builder.build(paragraph_text); + para_layout.break_all_lines(Some(150.0)); + para_layout.align(Some(150.0), Alignment::Start, AlignmentOptions::default()); + + render_layout( + scene, + ¶_layout, + &font_blob, + Affine::translate((32.0, 76.0)), + Color::from_rgb8(80, 80, 100), + ); + } +} + +fn render_layout( + scene: &mut Scene, + layout: &Layout<()>, + font_blob: &Blob, + transform: Affine, + color: Color, +) { + for line in layout.lines() { + for item in line.items() { + if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item { + let run = glyph_run.run(); + let parley_font = run.font(); + let font_data = FontData::new(font_blob.clone(), parley_font.index); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + let glyphs = glyph_run.positioned_glyphs().map(|g| Glyph { + id: g.id, + x: g.x, + y: g.y, + }); + + scene.draw_glyphs( + &font_data, + font_size, + false, + normalized_coords, + Fill::NonZero, + color, + 1.0, + transform, + None, + glyphs.into_iter(), + ); + } + } + } +} + /// Create a checkerboard image for demonstrating image brushes. fn create_checkerboard_image(width: u32, height: u32) -> ImageData { let mut pixels = Vec::with_capacity((width * height * 4) as usize); From 35ccfee272e363c745ca79a102195b2214863f82 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 06:37:43 +1030 Subject: [PATCH 05/15] . --- examples/serialize/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/serialize/src/main.rs b/examples/serialize/src/main.rs index 3c7724b..5cac4df 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -196,7 +196,7 @@ fn draw_text_with_parley(scene: &mut Scene) { let font_blob = Blob::from(include_bytes!("../../../assets/fonts/roboto/Roboto.ttf").to_vec()); font_cx.collection.register_fonts(font_blob.clone(), None); - let title_text = "Hello, AnyRender!"; + let title_text = "Hello World!"; let display_scale = 1.0; let mut builder = layout_cx.ranged_builder(&mut font_cx, title_text, display_scale, true); builder.push_default(StyleProperty::FontSize(18.0)); From b5ce997140b0210093e3bf32b37ed9e7a92f5670 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 06:38:59 +1030 Subject: [PATCH 06/15] . --- examples/serialize/src/main.rs | 36 +++++++++++++++------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/examples/serialize/src/main.rs b/examples/serialize/src/main.rs index 5cac4df..68f288f 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -196,22 +196,20 @@ fn draw_text_with_parley(scene: &mut Scene) { let font_blob = Blob::from(include_bytes!("../../../assets/fonts/roboto/Roboto.ttf").to_vec()); font_cx.collection.register_fonts(font_blob.clone(), None); - let title_text = "Hello World!"; - let display_scale = 1.0; - let mut builder = layout_cx.ranged_builder(&mut font_cx, title_text, display_scale, true); - builder.push_default(StyleProperty::FontSize(18.0)); - builder.push_default(StyleProperty::FontStack(FontStack::Single( - FontFamily::Named("Roboto".into()), - ))); - // Title { - let mut title_layout: Layout<()> = builder.build(title_text); - title_layout.break_all_lines(Some(140.0)); - title_layout.align(Some(140.0), Alignment::Start, AlignmentOptions::default()); + let text = "Hello World!"; + let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true); + builder.push_default(StyleProperty::FontSize(18.0)); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named("Roboto".into()), + ))); + let mut layout: Layout<()> = builder.build(text); + layout.break_all_lines(Some(140.0)); + layout.align(Some(140.0), Alignment::Start, AlignmentOptions::default()); render_layout( scene, - &title_layout, + &layout, &font_blob, Affine::translate((32.0, 50.0)), Color::from_rgb8(40, 40, 60), @@ -219,21 +217,19 @@ fn draw_text_with_parley(scene: &mut Scene) { } // Paragraph { - let paragraph_text = + let text = "Serialization roundtrip test: fonts are subsetted, compressed to WOFF2, and restored."; - let mut builder = - layout_cx.ranged_builder(&mut font_cx, paragraph_text, display_scale, true); + let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true); builder.push_default(StyleProperty::FontSize(13.0)); builder.push_default(StyleProperty::FontStack(FontStack::Single( FontFamily::Named("Roboto".into()), ))); - let mut para_layout: Layout<()> = builder.build(paragraph_text); - para_layout.break_all_lines(Some(150.0)); - para_layout.align(Some(150.0), Alignment::Start, AlignmentOptions::default()); - + let mut layout: Layout<()> = builder.build(text); + layout.break_all_lines(Some(150.0)); + layout.align(Some(150.0), Alignment::Start, AlignmentOptions::default()); render_layout( scene, - ¶_layout, + &layout, &font_blob, Affine::translate((32.0, 76.0)), Color::from_rgb8(80, 80, 100), From fa412103dfc9589abbdc177601ebed8fefba645b Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:26:42 +1030 Subject: [PATCH 07/15] Address comments --- .github/workflows/ci.yml | 9 + crates/anyrender_serialize/Cargo.toml | 11 +- crates/anyrender_serialize/src/lib.rs | 184 +++++++++++------- crates/anyrender_serialize/tests/serialize.rs | 10 +- 4 files changed, 145 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67bc4e0..59c87e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,15 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev - run: cargo build --workspace + build-features-powerset: + name: "Build [anyrender_serialize with all feature combinations]" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-hack + - run: cargo hack check --feature-powerset -p anyrender_serialize + fmt: name: Rustfmt runs-on: ubuntu-latest diff --git a/crates/anyrender_serialize/Cargo.toml b/crates/anyrender_serialize/Cargo.toml index de01168..13035e3 100644 --- a/crates/anyrender_serialize/Cargo.toml +++ b/crates/anyrender_serialize/Cargo.toml @@ -8,6 +8,11 @@ repository.workspace = true license.workspace = true edition.workspace = true +[features] +default = [] +subsetting = ["dep:klippa", "dep:read-fonts"] +woff2 = ["dep:ttf2woff2"] + [dependencies] anyrender = { workspace = true, features = ["serde"] } peniko = { workspace = true } @@ -17,9 +22,9 @@ serde_json = { workspace = true } zip = { workspace = true } sha2 = { workspace = true } image = { workspace = true, features = ["png"] } -klippa = { workspace = true } -read-fonts = { workspace = true } -ttf2woff2 = { workspace = true } +klippa = { workspace = true, optional = true } +read-fonts = { workspace = true, optional = true } +ttf2woff2 = { workspace = true, optional = true } wuff = { workspace = true } [dev-dependencies] diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 3175084..4d64c90 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -7,16 +7,22 @@ //! - `resources.json` - Metadata mapping resource files to IDs //! - `draw_commands.json` - Serialized draw commands referencing resources by ID //! - `images/.png` - Image files (PNG format) -//! - `fonts/.woff2` - Font data files (WOFF2 format, subsetted) +//! - `fonts/.{woff2,ttf}` - Font data files (optionally WOFF2-compressed and subsetted) -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +#[cfg(feature = "subsetting")] +use std::collections::HashSet; use std::io::{Read, Seek, Write}; use image::{ImageBuffer, ImageEncoder, RgbaImage}; +#[cfg(feature = "subsetting")] use klippa::{Plan, SubsetFlags}; use peniko::{Blob, Brush, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; +#[cfg(feature = "subsetting")] use read_fonts::FontRef; +#[cfg(feature = "subsetting")] use read_fonts::collections::int_set::IntSet; +#[cfg(feature = "subsetting")] use read_fonts::types::GlyphId; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -53,7 +59,7 @@ pub struct FontResourceId { pub struct SceneArchive { pub manifest: ResourceManifest, pub commands: Vec, - /// WOFF2-encoded font data (subsetted, one per font resource). + /// Font data (one per font resource, optionally WOFF2-compressed and/or subsetted). pub fonts: Vec>, pub images: Vec, } @@ -71,7 +77,7 @@ pub struct ResourceManifest { impl ResourceManifest { /// Current archive format version. Bump this when the format changes. - pub const CURRENT_VERSION: u32 = 2; + pub const CURRENT_VERSION: u32 = 1; pub fn new(tolerance: f64) -> Self { Self { @@ -98,8 +104,9 @@ pub struct ImageMetadata { /// Metadata for a font resource. /// -/// Fonts are stored as WOFF2 in the archive. During archival, TTC fonts are -/// extracted to standalone fonts and subsetted to only the glyphs used. +/// When the `woff2` feature is enabled, fonts are WOFF2-compressed. +/// When the `subsetting` feature is enabled, TTC fonts are extracted to +/// standalone fonts and subsetted to only the glyphs used. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FontMetadata { #[serde(flatten)] @@ -129,15 +136,24 @@ pub enum ResourceKind { /// Collects and deduplicates resources from a scene. struct ResourceCollector { - /// Maps (Blob ID, font index) to ResourceId for fonts. - /// Faces in a collection are extracted into standalone fonts, so this mapping enables us - /// to identify the correct font resource for a given face in a collection. + /// Maps `(Blob ID, font index)` to [`ResourceId`] for fonts. + /// + /// Each face in a collection is extracted into a standalone font during subsetting, so + /// the index is part of the key. Each face produces its own resource in the archive. + #[cfg(feature = "subsetting")] font_id_map: HashMap<(u64, u32), ResourceId>, + /// Maps Blob ID to [`ResourceId`] for fonts. + /// + /// Without subsetting, we store the raw font blob as-is (potentially a full TTC). + /// Multiple faces sharing the same blob are stored only once. + #[cfg(not(feature = "subsetting"))] + font_id_map: HashMap, /// Maps Blob ID to ResourceId for images image_id_map: HashMap, /// Collected fonts fonts: Vec, - /// Glyph IDs used for each font resource (parallel to `fonts`) + /// Glyph IDs used for each font resource (parallel to `fonts`). + #[cfg(feature = "subsetting")] font_glyph_ids: Vec>, /// Collected images images: Vec, @@ -149,6 +165,7 @@ impl ResourceCollector { font_id_map: HashMap::new(), image_id_map: HashMap::new(), fonts: Vec::new(), + #[cfg(feature = "subsetting")] font_glyph_ids: Vec::new(), images: Vec::new(), } @@ -156,7 +173,11 @@ impl ResourceCollector { /// Register a font and return its [`ResourceId`]. fn register_font(&mut self, font: &FontData) -> ResourceId { + #[cfg(feature = "subsetting")] let key = (font.data.id(), font.index); + #[cfg(not(feature = "subsetting"))] + let key = font.data.id(); + if let Some(&id) = self.font_id_map.get(&key) { return id; } @@ -164,11 +185,13 @@ impl ResourceCollector { let id = ResourceId(self.fonts.len()); self.font_id_map.insert(key, id); self.fonts.push(font.clone()); + #[cfg(feature = "subsetting")] self.font_glyph_ids.push(HashSet::new()); id } /// Record glyph IDs used for a font resource. + #[cfg(feature = "subsetting")] fn register_glyphs(&mut self, font_id: ResourceId, glyphs: &[anyrender::Glyph]) { let glyph_set = &mut self.font_glyph_ids[font_id.0]; for glyph in glyphs { @@ -228,12 +251,19 @@ impl ResourceCollector { }), RenderCommand::GlyphRun(glyph_run) => { let resource_id = self.register_font(&glyph_run.font_data); + #[cfg(feature = "subsetting")] self.register_glyphs(resource_id, &glyph_run.glyphs); let brush = self.convert_brush(&glyph_run.brush); SerializableRenderCommand::GlyphRun(GlyphRunCommand { font_data: FontResourceId { resource_id, - index: 0, // All faces are extracted into standalone fonts during archival. + // When subsetting is enabled, faces are extracted into standalone fonts + // (index always 0). Otherwise, preserve the original face index. + index: if cfg!(feature = "subsetting") { + 0 + } else { + glyph_run.font_data.index + }, }, font_size: glyph_run.font_size, hint: glyph_run.hint, @@ -317,7 +347,8 @@ impl ResourceReconstructor { shape: fill.shape.clone(), }), SerializableRenderCommand::GlyphRun(glyph_run) => { - let font_data = self.get_font(glyph_run.font_data.resource_id)?.clone(); + let font = self.get_font(glyph_run.font_data.resource_id)?; + let font_data = FontData::new(font.data.clone(), glyph_run.font_data.index); let brush = self.convert_brush(&glyph_run.brush)?; RenderCommand::GlyphRun(GlyphRunCommand { font_data, @@ -413,10 +444,11 @@ impl SceneArchive { /// Create a new SceneArchive from a recorded Scene. /// /// Font processing: - /// - TTC files are extracted to standalone fonts for each face used - /// - Fonts are subsetted to include only the glyphs referenced in the scene (using [`klippa`]`) - /// - Original glyph IDs are preserved (RETAIN_GIDS) — unused slots become empty - /// - Fonts are stored as WOFF2 + /// - `subsetting`: + /// - TTC files are extracted to standalone fonts for each face used + /// - Fonts are subsetted to only the glyphs referenced in the scene + /// - `woff2`: + /// - Fonts are WOFF2-compressed pub fn from_scene(scene: &Scene) -> Result { let mut manifest = ResourceManifest::new(scene.tolerance); let mut collector = ResourceCollector::new(); @@ -427,40 +459,47 @@ impl SceneArchive { .map(|cmd| collector.convert_command(cmd)) .collect(); - // For each collected font, subset to only the glyphs used and extract - // from TTC to standalone TTF. - let mut processed_fonts: Vec = Vec::with_capacity(collector.fonts.len()); + // When the `subsetting` feature is enabled, subset each font to only the glyphs used and + // extract faces from TTC into standalone TTFs. Otherwise, store fonts as-is. + #[cfg(feature = "subsetting")] + let processed_fonts: Vec = { + let mut result = Vec::with_capacity(collector.fonts.len()); + for (idx, font) in collector.fonts.iter().enumerate() { + let glyph_ids = &collector.font_glyph_ids[idx]; - for (idx, font) in collector.fonts.iter().enumerate() { - let glyph_ids = &collector.font_glyph_ids[idx]; + let font_ref = FontRef::from_index(font.data.data(), font.index).map_err(|e| { + ArchiveError::FontProcessing(format!("Failed to parse font: {e}")) + })?; - let font_ref = FontRef::from_index(font.data.data(), font.index) - .map_err(|e| ArchiveError::FontProcessing(format!("Failed to parse font: {e}")))?; + let mut input_gids: IntSet = IntSet::empty(); + for &gid in glyph_ids { + input_gids.insert(GlyphId::new(gid)); + } + + let plan = Plan::new( + &input_gids, + &IntSet::empty(), + &font_ref, + // keep original glyph IDs (so we don't need to remap them in the draw commands) + SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + ); + + let subset_data = klippa::subset_font(&font_ref, &plan).map_err(|e| { + ArchiveError::FontProcessing(format!("Font subsetting failed: {e}")) + })?; - let mut input_gids: IntSet = IntSet::empty(); - for &gid in glyph_ids { - input_gids.insert(GlyphId::new(gid)); + result.push(FontData::new(Blob::from(subset_data), 0)); } + result + }; - let plan = Plan::new( - &input_gids, - &IntSet::empty(), - &font_ref, - // keep original glyph IDs (so we don't need to remap them in the draw commands) - SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, - &IntSet::empty(), - &IntSet::empty(), - &IntSet::empty(), - &IntSet::empty(), - &IntSet::empty(), - ); - - let subset_data = klippa::subset_font(&font_ref, &plan).map_err(|e| { - ArchiveError::FontProcessing(format!("Font subsetting failed: {e}")) - })?; - - processed_fonts.push(FontData::new(Blob::from(subset_data), 0)); - } + #[cfg(not(feature = "subsetting"))] + let processed_fonts: Vec = collector.fonts; // Normalize all images to RGBA8 let images: Vec = collector @@ -500,28 +539,40 @@ impl SceneArchive { }); } - // WOFF2-encode each font and build metadata. + // Encode each font (WOFF2-compressed when the `woff2` feature is enabled, + // raw TTF/OTF otherwise) and build metadata. let mut fonts: Vec> = Vec::with_capacity(processed_fonts.len()); for (idx, font) in processed_fonts.iter().enumerate() { - let ttf_data = font.data.data(); - let woff2_data = - ttf2woff2::encode_no_transform(ttf_data, ttf2woff2::BrotliQuality::default()) + let raw_data = font.data.data(); + + #[cfg(feature = "woff2")] + let stored_data = + ttf2woff2::encode_no_transform(raw_data, ttf2woff2::BrotliQuality::default()) .map_err(|e| { ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")) })?; - let hash = sha256_hex(&woff2_data); - let path = format!("fonts/{}.woff2", hash); + + #[cfg(not(feature = "woff2"))] + let stored_data = raw_data.to_vec(); + + let hash = sha256_hex(&stored_data); + let extension = if cfg!(feature = "woff2") { + "woff2" + } else { + "ttf" + }; + let path = format!("fonts/{}.{}", hash, extension); manifest.fonts.push(FontMetadata { entry: ResourceEntry { id: ResourceId(idx), kind: ResourceKind::Font, - size: ttf_data.len(), + size: raw_data.len(), sha256_hash: hash, path, }, }); - fonts.push(Blob::from(woff2_data)); + fonts.push(Blob::from(stored_data)); } Ok(Self { @@ -551,19 +602,22 @@ impl SceneArchive { }) .collect::, ArchiveError>>()?; - // Decompress WOFF2 fonts to TTF + // Decode fonts: auto-detect WOFF2 (magic bytes: wOF2) and decompress if needed. + // The correct face index is stored in each command's FontResourceId and applied + // when reconstructing commands. let fonts_ttf: Vec = self .fonts .iter() - .map(|woff2| { - let ttf = wuff::decompress_woff2(woff2.data()).map_err(|e| { - ArchiveError::FontProcessing(format!("WOFF2 decoding failed: {e}")) - })?; - Ok(FontData::new( - Blob::from(ttf), - // Since we've extracted all faces into standalone fonts, the index is always 0. - 0, - )) + .map(|font_blob| { + let data = font_blob.data(); + let ttf_data = if data.starts_with(b"wOF2") { + wuff::decompress_woff2(data).map_err(|e| { + ArchiveError::FontProcessing(format!("WOFF2 decoding failed: {e}")) + })? + } else { + data.to_vec() + }; + Ok(FontData::new(Blob::from(ttf_data), 0)) }) .collect::, ArchiveError>>()?; @@ -608,7 +662,7 @@ impl SceneArchive { zip.write_all(&png_data)?; } - // Write font files as WOFF2 + // Write font files for (idx, woff2_data) in self.fonts.iter().enumerate() { let path = &self.manifest.fonts[idx].entry.path; zip.start_file(path, options)?; @@ -670,7 +724,7 @@ impl SceneArchive { }); } - // Read fonts (WOFF2 compressed) + // Read fonts (may be WOFF2-compressed or raw TTF/OTF) let mut fonts: Vec> = Vec::with_capacity(manifest.fonts.len()); for meta in &manifest.fonts { let mut file = zip.by_name(&meta.entry.path)?; diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index 060b5ef..0dd6f08 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -12,6 +12,7 @@ use peniko::{ Blob, Brush, Color, Compose, Fill, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix, }; +#[cfg(all(feature = "subsetting", feature = "woff2"))] use read_fonts::TableProvider; use zip::ZipArchive; @@ -204,6 +205,7 @@ fn test_multiple_different_images() { #[test] fn test_glyph_run_roundtrip() { let font = roboto_font(); + #[cfg(feature = "subsetting")] let original_font_size = font.data.data().len(); let mut scene = Scene::new(); @@ -250,6 +252,8 @@ fn test_glyph_run_roundtrip() { // Verify font metadata assert_eq!(archive.manifest.fonts.len(), 1); + + #[cfg(feature = "subsetting")] assert!( archive.manifest.fonts[0].entry.size < original_font_size, "Subsetted font ({} bytes) should be smaller than original ({} bytes)", @@ -258,9 +262,13 @@ fn test_glyph_run_roundtrip() { ); // Verify the WOFF2 file path + #[cfg(feature = "woff2")] assert!(archive.manifest.fonts[0].entry.path.ends_with(".woff2")); + #[cfg(not(feature = "woff2"))] + assert!(archive.manifest.fonts[0].entry.path.ends_with(".ttf")); // Verify subsetting + #[cfg(all(feature = "subsetting", feature = "woff2"))] { let ttf_data = wuff::decompress_woff2(archive.fonts[0].data()).unwrap(); let font_ref = read_fonts::FontRef::new(&ttf_data).unwrap(); @@ -348,7 +356,7 @@ fn test_font_deduplication() { #[test] fn test_resource_manifest_version() { - assert_eq!(ResourceManifest::CURRENT_VERSION, 2); + assert_eq!(ResourceManifest::CURRENT_VERSION, 1); } #[test] From 0aaf2786b986a4fae84ba46bda546bd228338e6e Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:45:17 +1030 Subject: [PATCH 08/15] Use FontWriter --- crates/anyrender_serialize/src/font_writer.rs | 199 ++++++++++++++++++ crates/anyrender_serialize/src/lib.rs | 183 +++------------- examples/serialize/Cargo.toml | 2 +- 3 files changed, 226 insertions(+), 158 deletions(-) create mode 100644 crates/anyrender_serialize/src/font_writer.rs diff --git a/crates/anyrender_serialize/src/font_writer.rs b/crates/anyrender_serialize/src/font_writer.rs new file mode 100644 index 0000000..283a595 --- /dev/null +++ b/crates/anyrender_serialize/src/font_writer.rs @@ -0,0 +1,199 @@ +//! Write-side font processing: collection, deduplication, subsetting, and encoding. + +use std::collections::HashMap; +#[cfg(feature = "subsetting")] +use std::collections::HashSet; + +#[cfg(feature = "subsetting")] +use klippa::{Plan, SubsetFlags}; +use peniko::FontData; +#[cfg(feature = "subsetting")] +use read_fonts::FontRef; +#[cfg(feature = "subsetting")] +use read_fonts::collections::int_set::IntSet; +#[cfg(feature = "subsetting")] +use read_fonts::types::GlyphId; +use sha2::{Digest, Sha256}; + +use crate::{ArchiveError, ResourceId}; + +/// A font that has been processed (optionally subsetted and/or WOFF2-encoded) and is +/// ready to be written into the archive. +pub(crate) struct ProcessedFont { + /// Size of the raw (uncompressed) font data in bytes. + pub raw_size: usize, + /// The stored font data (WOFF2-compressed or raw TTF/OTF depending on features). + pub stored_data: Vec, + /// SHA-256 hex hash of `stored_data`. + pub hash: String, + /// Archive-relative path (e.g. `fonts/.woff2` or `fonts/.ttf`). + pub path: String, +} + +/// Collects, deduplicates, and processes fonts for writing into a scene archive. +/// +/// When the `subsetting` feature is enabled, each `(blob, face index)` pair is treated +/// as a distinct resource because subsetting extracts each face into a standalone font. +/// +/// When disabled, fonts are deduplicated by blob alone — multiple faces sharing the same TTC +/// are stored together. +pub(crate) struct FontWriter { + /// Map `(Blob ID, face index)` to [`ResourceId`]. + #[cfg(feature = "subsetting")] + id_map: HashMap<(u64, u32), ResourceId>, + /// Map `Blob ID` to [`ResourceId`]. + #[cfg(not(feature = "subsetting"))] + id_map: HashMap, + + fonts: Vec, + + #[cfg(feature = "subsetting")] + glyph_ids: Vec>, +} + +impl FontWriter { + pub fn new() -> Self { + Self { + id_map: HashMap::new(), + fonts: Vec::new(), + #[cfg(feature = "subsetting")] + glyph_ids: Vec::new(), + } + } + + /// Register a font and return its [`ResourceId`]. + pub fn register(&mut self, font: &FontData) -> ResourceId { + #[cfg(feature = "subsetting")] + let key = (font.data.id(), font.index); + #[cfg(not(feature = "subsetting"))] + let key = font.data.id(); + + if let Some(&id) = self.id_map.get(&key) { + return id; + } + + let id = ResourceId(self.fonts.len()); + self.id_map.insert(key, id); + self.fonts.push(font.clone()); + #[cfg(feature = "subsetting")] + self.glyph_ids.push(HashSet::new()); + id + } + + /// Record glyph IDs used for a font resource (used for subsetting). + pub fn record_glyphs(&mut self, id: ResourceId, glyphs: &[anyrender::Glyph]) { + #[cfg(feature = "subsetting")] + { + let glyph_set = &mut self.glyph_ids[id.0]; + for glyph in glyphs { + glyph_set.insert(glyph.id); + } + } + #[cfg(not(feature = "subsetting"))] + { + let _ = (id, glyphs); + } + } + + /// The face index to store in [`crate::FontResourceId`]. + /// + /// When subsetting is enabled, faces are extracted into standalone fonts so the index + /// is always 0. Otherwise the original face index is preserved. + pub fn face_index(&self, font: &FontData) -> u32 { + #[cfg(feature = "subsetting")] + { + let _ = font; + 0 + } + #[cfg(not(feature = "subsetting"))] + { + font.index + } + } + + /// Consume the writer, returning an iterator of processed fonts ready for the archive. + pub fn into_processed(self) -> impl Iterator> { + #[cfg(feature = "subsetting")] + let glyph_ids = self.glyph_ids; + + self.fonts.into_iter().enumerate().map(move |(_idx, font)| { + // Conditionally subset. + #[cfg(feature = "subsetting")] + let raw_data = { + let font_glyph_ids = &glyph_ids[_idx]; + + let font_ref = FontRef::from_index(font.data.data(), font.index).map_err(|e| { + ArchiveError::FontProcessing(format!("Failed to parse font: {e}")) + })?; + + let mut input_gids: IntSet = IntSet::empty(); + for &gid in font_glyph_ids { + input_gids.insert(GlyphId::new(gid)); + } + + let plan = Plan::new( + &input_gids, + &IntSet::empty(), + &font_ref, + // Keep original glyph IDs so we don't need to remap them in draw commands. + SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + ); + + klippa::subset_font(&font_ref, &plan).map_err(|e| { + ArchiveError::FontProcessing(format!("Font subsetting failed: {e}")) + })? + }; + #[cfg(not(feature = "subsetting"))] + let raw_data = font.data.data().to_vec(); + + let raw_size = raw_data.len(); + + // Conditionally WOFF2 compress. + #[cfg(feature = "woff2")] + let stored_data = + ttf2woff2::encode_no_transform(&raw_data, ttf2woff2::BrotliQuality::default()) + .map_err(|e| { + ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")) + })?; + #[cfg(not(feature = "woff2"))] + let stored_data = raw_data; + + let hash = sha256_hex(&stored_data); + let extension = if cfg!(feature = "woff2") { + "woff2" + } else { + "ttf" + }; + let path = format!("fonts/{}.{}", hash, extension); + + Ok(ProcessedFont { + raw_size, + stored_data, + hash, + path, + }) + }) + } +} + +fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + let result = hasher.finalize(); + hex_encode(&result) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX_CHARS: &[u8; 16] = b"0123456789abcdef"; + let mut hex = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + hex.push(HEX_CHARS[(byte >> 4) as usize] as char); + hex.push(HEX_CHARS[(byte & 0xf) as usize] as char); + } + hex +} diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 4d64c90..b6eed1f 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -10,20 +10,10 @@ //! - `fonts/.{woff2,ttf}` - Font data files (optionally WOFF2-compressed and subsetted) use std::collections::HashMap; -#[cfg(feature = "subsetting")] -use std::collections::HashSet; use std::io::{Read, Seek, Write}; use image::{ImageBuffer, ImageEncoder, RgbaImage}; -#[cfg(feature = "subsetting")] -use klippa::{Plan, SubsetFlags}; use peniko::{Blob, Brush, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; -#[cfg(feature = "subsetting")] -use read_fonts::FontRef; -#[cfg(feature = "subsetting")] -use read_fonts::collections::int_set::IntSet; -#[cfg(feature = "subsetting")] -use read_fonts::types::GlyphId; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zip::write::SimpleFileOptions; @@ -31,8 +21,11 @@ use zip::{ZipArchive, ZipWriter}; use anyrender::recording::{FillCommand, GlyphRunCommand, RenderCommand, Scene, StrokeCommand}; +mod font_writer; mod json_formatter; +use font_writer::FontWriter; + /// A render command with resources replaced by IDs. pub type SerializableRenderCommand = RenderCommand; @@ -136,25 +129,9 @@ pub enum ResourceKind { /// Collects and deduplicates resources from a scene. struct ResourceCollector { - /// Maps `(Blob ID, font index)` to [`ResourceId`] for fonts. - /// - /// Each face in a collection is extracted into a standalone font during subsetting, so - /// the index is part of the key. Each face produces its own resource in the archive. - #[cfg(feature = "subsetting")] - font_id_map: HashMap<(u64, u32), ResourceId>, - /// Maps Blob ID to [`ResourceId`] for fonts. - /// - /// Without subsetting, we store the raw font blob as-is (potentially a full TTC). - /// Multiple faces sharing the same blob are stored only once. - #[cfg(not(feature = "subsetting"))] - font_id_map: HashMap, + fonts: FontWriter, /// Maps Blob ID to ResourceId for images image_id_map: HashMap, - /// Collected fonts - fonts: Vec, - /// Glyph IDs used for each font resource (parallel to `fonts`). - #[cfg(feature = "subsetting")] - font_glyph_ids: Vec>, /// Collected images images: Vec, } @@ -162,43 +139,12 @@ struct ResourceCollector { impl ResourceCollector { fn new() -> Self { Self { - font_id_map: HashMap::new(), + fonts: FontWriter::new(), image_id_map: HashMap::new(), - fonts: Vec::new(), - #[cfg(feature = "subsetting")] - font_glyph_ids: Vec::new(), images: Vec::new(), } } - /// Register a font and return its [`ResourceId`]. - fn register_font(&mut self, font: &FontData) -> ResourceId { - #[cfg(feature = "subsetting")] - let key = (font.data.id(), font.index); - #[cfg(not(feature = "subsetting"))] - let key = font.data.id(); - - if let Some(&id) = self.font_id_map.get(&key) { - return id; - } - - let id = ResourceId(self.fonts.len()); - self.font_id_map.insert(key, id); - self.fonts.push(font.clone()); - #[cfg(feature = "subsetting")] - self.font_glyph_ids.push(HashSet::new()); - id - } - - /// Record glyph IDs used for a font resource. - #[cfg(feature = "subsetting")] - fn register_glyphs(&mut self, font_id: ResourceId, glyphs: &[anyrender::Glyph]) { - let glyph_set = &mut self.font_glyph_ids[font_id.0]; - for glyph in glyphs { - glyph_set.insert(glyph.id); - } - } - /// Register an image and return its [`ResourceId`]. fn register_image(&mut self, image: &ImageData) -> ResourceId { let blob_id = image.data.id(); @@ -250,20 +196,13 @@ impl ResourceCollector { shape: fill.shape.clone(), }), RenderCommand::GlyphRun(glyph_run) => { - let resource_id = self.register_font(&glyph_run.font_data); - #[cfg(feature = "subsetting")] - self.register_glyphs(resource_id, &glyph_run.glyphs); + let resource_id = self.fonts.register(&glyph_run.font_data); + self.fonts.record_glyphs(resource_id, &glyph_run.glyphs); let brush = self.convert_brush(&glyph_run.brush); SerializableRenderCommand::GlyphRun(GlyphRunCommand { font_data: FontResourceId { resource_id, - // When subsetting is enabled, faces are extracted into standalone fonts - // (index always 0). Otherwise, preserve the original face index. - index: if cfg!(feature = "subsetting") { - 0 - } else { - glyph_run.font_data.index - }, + index: self.fonts.face_index(&glyph_run.font_data), }, font_size: glyph_run.font_size, hint: glyph_run.hint, @@ -443,12 +382,6 @@ fn convert_to_rgba(image: &ImageData) -> Result, ArchiveError> { impl SceneArchive { /// Create a new SceneArchive from a recorded Scene. /// - /// Font processing: - /// - `subsetting`: - /// - TTC files are extracted to standalone fonts for each face used - /// - Fonts are subsetted to only the glyphs referenced in the scene - /// - `woff2`: - /// - Fonts are WOFF2-compressed pub fn from_scene(scene: &Scene) -> Result { let mut manifest = ResourceManifest::new(scene.tolerance); let mut collector = ResourceCollector::new(); @@ -459,47 +392,21 @@ impl SceneArchive { .map(|cmd| collector.convert_command(cmd)) .collect(); - // When the `subsetting` feature is enabled, subset each font to only the glyphs used and - // extract faces from TTC into standalone TTFs. Otherwise, store fonts as-is. - #[cfg(feature = "subsetting")] - let processed_fonts: Vec = { - let mut result = Vec::with_capacity(collector.fonts.len()); - for (idx, font) in collector.fonts.iter().enumerate() { - let glyph_ids = &collector.font_glyph_ids[idx]; - - let font_ref = FontRef::from_index(font.data.data(), font.index).map_err(|e| { - ArchiveError::FontProcessing(format!("Failed to parse font: {e}")) - })?; - - let mut input_gids: IntSet = IntSet::empty(); - for &gid in glyph_ids { - input_gids.insert(GlyphId::new(gid)); - } - - let plan = Plan::new( - &input_gids, - &IntSet::empty(), - &font_ref, - // keep original glyph IDs (so we don't need to remap them in the draw commands) - SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, - &IntSet::empty(), - &IntSet::empty(), - &IntSet::empty(), - &IntSet::empty(), - &IntSet::empty(), - ); - - let subset_data = klippa::subset_font(&font_ref, &plan).map_err(|e| { - ArchiveError::FontProcessing(format!("Font subsetting failed: {e}")) - })?; - - result.push(FontData::new(Blob::from(subset_data), 0)); - } - result - }; - - #[cfg(not(feature = "subsetting"))] - let processed_fonts: Vec = collector.fonts; + // Process fonts (subset, encode) via FontWriter. + let mut fonts = Vec::new(); + for (idx, result) in collector.fonts.into_processed().enumerate() { + let font = result?; + manifest.fonts.push(FontMetadata { + entry: ResourceEntry { + id: ResourceId(idx), + kind: ResourceKind::Font, + size: font.raw_size, + sha256_hash: font.hash, + path: font.path, + }, + }); + fonts.push(Blob::from(font.stored_data)); + } // Normalize all images to RGBA8 let images: Vec = collector @@ -539,42 +446,6 @@ impl SceneArchive { }); } - // Encode each font (WOFF2-compressed when the `woff2` feature is enabled, - // raw TTF/OTF otherwise) and build metadata. - let mut fonts: Vec> = Vec::with_capacity(processed_fonts.len()); - for (idx, font) in processed_fonts.iter().enumerate() { - let raw_data = font.data.data(); - - #[cfg(feature = "woff2")] - let stored_data = - ttf2woff2::encode_no_transform(raw_data, ttf2woff2::BrotliQuality::default()) - .map_err(|e| { - ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")) - })?; - - #[cfg(not(feature = "woff2"))] - let stored_data = raw_data.to_vec(); - - let hash = sha256_hex(&stored_data); - let extension = if cfg!(feature = "woff2") { - "woff2" - } else { - "ttf" - }; - let path = format!("fonts/{}.{}", hash, extension); - - manifest.fonts.push(FontMetadata { - entry: ResourceEntry { - id: ResourceId(idx), - kind: ResourceKind::Font, - size: raw_data.len(), - sha256_hash: hash, - path, - }, - }); - fonts.push(Blob::from(stored_data)); - } - Ok(Self { manifest, commands, @@ -602,9 +473,7 @@ impl SceneArchive { }) .collect::, ArchiveError>>()?; - // Decode fonts: auto-detect WOFF2 (magic bytes: wOF2) and decompress if needed. - // The correct face index is stored in each command's FontResourceId and applied - // when reconstructing commands. + // Decode fonts. let fonts_ttf: Vec = self .fonts .iter() @@ -663,10 +532,10 @@ impl SceneArchive { } // Write font files - for (idx, woff2_data) in self.fonts.iter().enumerate() { + for (idx, font_data) in self.fonts.iter().enumerate() { let path = &self.manifest.fonts[idx].entry.path; zip.start_file(path, options)?; - zip.write_all(woff2_data.data())?; + zip.write_all(font_data.data())?; } zip.finish()?; diff --git a/examples/serialize/Cargo.toml b/examples/serialize/Cargo.toml index 09af45c..e81cbde 100644 --- a/examples/serialize/Cargo.toml +++ b/examples/serialize/Cargo.toml @@ -10,6 +10,6 @@ kurbo = { workspace = true } peniko = { workspace = true } image = { workspace = true, features = ["png"] } anyrender = { workspace = true, features = ["serde"] } -anyrender_serialize = { workspace = true } +anyrender_serialize = { workspace = true, features = ["subsetting", "woff2"] } anyrender_vello_cpu = { workspace = true } parley = { version = "0.7", default-features = false, features = ["std"] } From 3c38c925c04d4fa413e6b14dcca66889bb637c07 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:46:07 +1030 Subject: [PATCH 09/15] . --- crates/anyrender_serialize/src/font_writer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/anyrender_serialize/src/font_writer.rs b/crates/anyrender_serialize/src/font_writer.rs index 283a595..a73c625 100644 --- a/crates/anyrender_serialize/src/font_writer.rs +++ b/crates/anyrender_serialize/src/font_writer.rs @@ -35,7 +35,7 @@ pub(crate) struct ProcessedFont { /// When the `subsetting` feature is enabled, each `(blob, face index)` pair is treated /// as a distinct resource because subsetting extracts each face into a standalone font. /// -/// When disabled, fonts are deduplicated by blob alone — multiple faces sharing the same TTC +/// When disabled, fonts are deduplicated by blob alone. Multiple faces sharing the same TTC /// are stored together. pub(crate) struct FontWriter { /// Map `(Blob ID, face index)` to [`ResourceId`]. From fac31c6e25143f3608a7f58b9433c1bc936c808f Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:46:42 +1030 Subject: [PATCH 10/15] . --- crates/anyrender_serialize/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index b6eed1f..30a9f1d 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -381,7 +381,6 @@ fn convert_to_rgba(image: &ImageData) -> Result, ArchiveError> { impl SceneArchive { /// Create a new SceneArchive from a recorded Scene. - /// pub fn from_scene(scene: &Scene) -> Result { let mut manifest = ResourceManifest::new(scene.tolerance); let mut collector = ResourceCollector::new(); From 1918aa430650515f44f15eed357f0cfb9d01d0d0 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:47:08 +1030 Subject: [PATCH 11/15] . --- crates/anyrender_serialize/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 30a9f1d..c518ebd 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -391,7 +391,7 @@ impl SceneArchive { .map(|cmd| collector.convert_command(cmd)) .collect(); - // Process fonts (subset, encode) via FontWriter. + // Process fonts. let mut fonts = Vec::new(); for (idx, result) in collector.fonts.into_processed().enumerate() { let font = result?; From 89587d87af2906f2c51976c13ced25ad6f5228b8 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:48:30 +1030 Subject: [PATCH 12/15] . --- crates/anyrender_serialize/src/font_writer.rs | 19 +------------------ crates/anyrender_serialize/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/crates/anyrender_serialize/src/font_writer.rs b/crates/anyrender_serialize/src/font_writer.rs index a73c625..042c7a9 100644 --- a/crates/anyrender_serialize/src/font_writer.rs +++ b/crates/anyrender_serialize/src/font_writer.rs @@ -15,7 +15,7 @@ use read_fonts::collections::int_set::IntSet; use read_fonts::types::GlyphId; use sha2::{Digest, Sha256}; -use crate::{ArchiveError, ResourceId}; +use crate::{ArchiveError, ResourceId, sha256_hex}; /// A font that has been processed (optionally subsetted and/or WOFF2-encoded) and is /// ready to be written into the archive. @@ -180,20 +180,3 @@ impl FontWriter { }) } } - -fn sha256_hex(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - let result = hasher.finalize(); - hex_encode(&result) -} - -fn hex_encode(bytes: &[u8]) -> String { - const HEX_CHARS: &[u8; 16] = b"0123456789abcdef"; - let mut hex = String::with_capacity(bytes.len() * 2); - for &byte in bytes { - hex.push(HEX_CHARS[(byte >> 4) as usize] as char); - hex.push(HEX_CHARS[(byte & 0xf) as usize] as char); - } - hex -} diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index c518ebd..2e85c79 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -309,7 +309,7 @@ impl ResourceReconstructor { } } -fn sha256_hex(data: &[u8]) -> String { +pub(crate) fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); let result = hasher.finalize(); From 3a2d6c04a7e1f5873c1d3fd4b85abe64161c7df2 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:49:34 +1030 Subject: [PATCH 13/15] . --- crates/anyrender_serialize/src/lib.rs | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 2e85c79..a0b0167 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -391,22 +391,6 @@ impl SceneArchive { .map(|cmd| collector.convert_command(cmd)) .collect(); - // Process fonts. - let mut fonts = Vec::new(); - for (idx, result) in collector.fonts.into_processed().enumerate() { - let font = result?; - manifest.fonts.push(FontMetadata { - entry: ResourceEntry { - id: ResourceId(idx), - kind: ResourceKind::Font, - size: font.raw_size, - sha256_hash: font.hash, - path: font.path, - }, - }); - fonts.push(Blob::from(font.stored_data)); - } - // Normalize all images to RGBA8 let images: Vec = collector .images @@ -445,6 +429,22 @@ impl SceneArchive { }); } + // Process fonts. + let mut fonts = Vec::new(); + for (idx, result) in collector.fonts.into_processed().enumerate() { + let font = result?; + manifest.fonts.push(FontMetadata { + entry: ResourceEntry { + id: ResourceId(idx), + kind: ResourceKind::Font, + size: font.raw_size, + sha256_hash: font.hash, + path: font.path, + }, + }); + fonts.push(Blob::from(font.stored_data)); + } + Ok(Self { manifest, commands, From 1370282c901e7e0d053797ec321c3226fc7e8d42 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Sun, 8 Feb 2026 14:49:55 +1030 Subject: [PATCH 14/15] . --- crates/anyrender_serialize/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index a0b0167..53f7589 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -429,7 +429,7 @@ impl SceneArchive { }); } - // Process fonts. + // Add font metadata. let mut fonts = Vec::new(); for (idx, result) in collector.fonts.into_processed().enumerate() { let font = result?; From 33390dcdf330ad7b71d87f078c70cd344ee79316 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Mon, 9 Feb 2026 04:56:35 +1100 Subject: [PATCH 15/15] . --- crates/anyrender_serialize/src/font_writer.rs | 1 - crates/anyrender_serialize/tests/serialize.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/anyrender_serialize/src/font_writer.rs b/crates/anyrender_serialize/src/font_writer.rs index 042c7a9..acab718 100644 --- a/crates/anyrender_serialize/src/font_writer.rs +++ b/crates/anyrender_serialize/src/font_writer.rs @@ -13,7 +13,6 @@ use read_fonts::FontRef; use read_fonts::collections::int_set::IntSet; #[cfg(feature = "subsetting")] use read_fonts::types::GlyphId; -use sha2::{Digest, Sha256}; use crate::{ArchiveError, ResourceId, sha256_hex}; diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index 0dd6f08..b5b65d4 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -380,7 +380,7 @@ fn test_archive_contains_expected_files() { .read_to_string(&mut resources_json) .unwrap(); let manifest: ResourceManifest = serde_json::from_str(&resources_json).unwrap(); - assert_eq!(manifest.version, 2); + assert_eq!(manifest.version, 1); assert!(manifest.images.is_empty()); assert!(manifest.fonts.is_empty());