diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59c87e4..67bc4e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,15 +44,6 @@ 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 13035e3..9b3bede 100644 --- a/crates/anyrender_serialize/Cargo.toml +++ b/crates/anyrender_serialize/Cargo.toml @@ -8,11 +8,6 @@ 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 } @@ -22,15 +17,14 @@ serde_json = { workspace = true } zip = { workspace = true } sha2 = { workspace = true } image = { workspace = true, features = ["png"] } -klippa = { workspace = true, optional = true } -read-fonts = { workspace = true, optional = true } -ttf2woff2 = { workspace = true, optional = true } +klippa = { workspace = true } +read-fonts = { workspace = true } +ttf2woff2 = { workspace = true } 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/font_writer.rs b/crates/anyrender_serialize/src/font_writer.rs index acab718..f80f201 100644 --- a/crates/anyrender_serialize/src/font_writer.rs +++ b/crates/anyrender_serialize/src/font_writer.rs @@ -1,27 +1,21 @@ //! Write-side font processing: collection, deduplication, subsetting, and encoding. -use std::collections::HashMap; -#[cfg(feature = "subsetting")] -use std::collections::HashSet; +use std::collections::{HashMap, 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 crate::{ArchiveError, ResourceId, sha256_hex}; +use crate::{ArchiveError, ResourceId, SerializeConfig, sha256_hex}; /// 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). + /// The stored font data (WOFF2-compressed or raw TTF/OTF depending on config). pub stored_data: Vec, /// SHA-256 hex hash of `stored_data`. pub hash: String, @@ -31,41 +25,40 @@ pub(crate) struct ProcessedFont { /// 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 subsetting 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 { + config: SerializeConfig, /// Map `(Blob ID, face index)` to [`ResourceId`]. - #[cfg(feature = "subsetting")] + /// When subsetting is disabled, the face in the `(Blob ID, face index)` tuple is always 0. + /// This is because multiple faces sharing the same TTC should be keyed together. 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 { + pub fn new(config: SerializeConfig) -> Self { Self { + config, 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(); + let key = if self.config.subset_fonts { + (font.data.id(), font.index) + } else { + // When subsetting is disabled, the face index is always 0 so that + // multiple faces sharing the same TTC are keyed together. + (font.data.id(), 0) + }; if let Some(&id) = self.id_map.get(&key) { return id; @@ -74,24 +67,18 @@ impl FontWriter { 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")] - { + if self.config.subset_fonts { 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`]. @@ -99,34 +86,25 @@ impl FontWriter { /// 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; + if self.config.subset_fonts { 0 - } - #[cfg(not(feature = "subsetting"))] - { + } else { 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]; + self.fonts.into_iter().enumerate().map(move |(idx, font)| { + let raw_data = if self.config.subset_fonts { + let glyph_ids = &self.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 { + for &gid in glyph_ids { input_gids.insert(GlyphId::new(gid)); } @@ -146,24 +124,24 @@ impl FontWriter { klippa::subset_font(&font_ref, &plan).map_err(|e| { ArchiveError::FontProcessing(format!("Font subsetting failed: {e}")) })? + } else { + font.data.data().to_vec() }; - #[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 = + let stored_data = if self.config.woff2_fonts { 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; + })? + } else { + raw_data + }; let hash = sha256_hex(&stored_data); - let extension = if cfg!(feature = "woff2") { + let extension = if self.config.woff2_fonts { "woff2" } else { "ttf" diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 53f7589..e573d73 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -97,9 +97,9 @@ pub struct ImageMetadata { /// Metadata for a font resource. /// -/// 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. +/// When WOFF2 is enabled via [`SerializeConfig`], fonts are WOFF2-compressed. +/// When subsetting 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)] @@ -137,9 +137,9 @@ struct ResourceCollector { } impl ResourceCollector { - fn new() -> Self { + fn new(config: SerializeConfig) -> Self { Self { - fonts: FontWriter::new(), + fonts: FontWriter::new(config), image_id_map: HashMap::new(), images: Vec::new(), } @@ -381,9 +381,9 @@ 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 { + pub fn from_scene(scene: &Scene, config: &SerializeConfig) -> Result { let mut manifest = ResourceManifest::new(scene.tolerance); - let mut collector = ResourceCollector::new(); + let mut collector = ResourceCollector::new(config.clone()); let commands: Vec<_> = scene .commands @@ -619,6 +619,30 @@ impl SceneArchive { } } +#[derive(Clone, Debug, Default)] +pub struct SerializeConfig { + subset_fonts: bool, + woff2_fonts: bool, +} + +impl SerializeConfig { + pub fn new() -> Self { + Self::default() + } + + /// Subset fonts to only include glyphs used in the scene. + pub fn with_subset_fonts(mut self, subset_fonts: bool) -> Self { + self.subset_fonts = subset_fonts; + self + } + + /// WOFF2-compress font data. + pub fn with_woff2_fonts(mut self, woff2_fonts: bool) -> Self { + self.woff2_fonts = woff2_fonts; + self + } +} + #[derive(Debug)] pub enum ArchiveError { Io(std::io::Error), diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index b5b65d4..8716fab 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -5,14 +5,13 @@ use std::io::{Cursor, Read}; use anyrender::recording::{RenderCommand, Scene}; use anyrender::{Glyph, PaintScene}; use anyrender_serialize::{ - ArchiveError, ResourceManifest, SceneArchive, SerializableRenderCommand, + ArchiveError, ResourceManifest, SceneArchive, SerializableRenderCommand, SerializeConfig, }; use kurbo::{Affine, Rect, Stroke}; 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; @@ -127,7 +126,7 @@ fn test_image_data_roundtrip() { &Rect::new(0.0, 0.0, 100.0, 100.0), ); - let data = serialize_to_vec(&scene).unwrap(); + let data = serialize_to_vec(&scene, &default_config()).unwrap(); let archive = archive_deserialize_from_slice(&data).unwrap(); // Verify manifest metadata @@ -162,7 +161,7 @@ fn test_image_deduplication() { &Rect::new(0.0, 0.0, 50.0, 50.0), ); - let archive = SceneArchive::from_scene(&scene).unwrap(); + let archive = SceneArchive::from_scene(&scene, &default_config()).unwrap(); assert_eq!(archive.commands.len(), 2); assert_eq!(archive.images.len(), 1); // deduplicated } @@ -188,7 +187,7 @@ fn test_multiple_different_images() { &Rect::new(0.0, 0.0, 50.0, 50.0), ); - let archive = SceneArchive::from_scene(&scene).unwrap(); + let archive = SceneArchive::from_scene(&scene, &default_config()).unwrap(); assert_eq!(archive.commands.len(), 2); assert_eq!(archive.images.len(), 2); @@ -205,55 +204,32 @@ 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(); - let glyphs = [ - Glyph { - id: 43, - x: 0.0, - y: 0.0, - }, - Glyph { - id: 72, - x: 10.0, - y: 0.0, - }, - Glyph { - id: 79, - x: 20.0, - y: 0.0, - }, - ]; - let font_size = 16.0; - let hint = false; - let normalized_coords = []; - let style = Fill::NonZero; - let brush = Color::from_rgb8(0, 0, 0); - let brush_alpha = 1.0; - let transform = Affine::translate((10.0, 50.0)); - let glyph_transform = None; - scene.draw_glyphs( - &font, - font_size, - hint, - &normalized_coords, - style, - brush, - brush_alpha, - transform, - glyph_transform, - glyphs.into_iter(), - ); - let data = serialize_to_vec(&scene).unwrap(); + let scene = build_glyph_scene(&font); + let data = serialize_to_vec(&scene, &default_config()).unwrap(); let archive = archive_deserialize_from_slice(&data).unwrap(); // Verify font metadata assert_eq!(archive.manifest.fonts.len(), 1); - #[cfg(feature = "subsetting")] + assert!(archive.manifest.fonts[0].entry.path.ends_with(".ttf")); + assert_eq!(archive.manifest.fonts[0].entry.size, font.data.data().len(),); + let restored = archive.to_scene().unwrap(); + assert_glyph_run_preserved(&restored); +} + +#[test] +fn test_glyph_run_roundtrip_with_subsetting_and_woff2() { + let font = roboto_font(); + let original_font_size = font.data.data().len(); + + let scene = build_glyph_scene(&font); + let config = subset_and_woff2_config(); + let data = serialize_to_vec(&scene, &config).unwrap(); + let archive = archive_deserialize_from_slice(&data).unwrap(); + + assert_eq!(archive.manifest.fonts.len(), 1); + assert!(archive.manifest.fonts[0].entry.path.ends_with(".woff2")); assert!( archive.manifest.fonts[0].entry.size < original_font_size, "Subsetted font ({} bytes) should be smaller than original ({} bytes)", @@ -261,14 +237,7 @@ fn test_glyph_run_roundtrip() { original_font_size ); - // 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(); @@ -299,28 +268,7 @@ fn test_glyph_run_roundtrip() { // Verify the scene roundtrip let restored = archive.to_scene().unwrap(); - assert_eq!(restored.commands.len(), 1); - - match &restored.commands[0] { - RenderCommand::GlyphRun(glyph_run) => { - 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.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:?}"), - } + assert_glyph_run_preserved(&restored); } #[test] @@ -349,7 +297,7 @@ fn test_font_deduplication() { ); } - let archive = SceneArchive::from_scene(&scene).unwrap(); + let archive = SceneArchive::from_scene(&scene, &default_config()).unwrap(); assert_eq!(archive.commands.len(), 2); assert_eq!(archive.fonts.len(), 1); // deduplicated } @@ -370,7 +318,7 @@ fn test_archive_contains_expected_files() { &Rect::new(0.0, 0.0, 100.0, 100.0), ); - let data = serialize_to_vec(&scene).unwrap(); + let data = serialize_to_vec(&scene, &default_config()).unwrap(); let mut zip = ZipArchive::new(Cursor::new(&data)).unwrap(); // Verify resources.json @@ -396,9 +344,19 @@ fn test_archive_contains_expected_files() { // Helpers -fn serialize_to_vec(scene: &Scene) -> Result, ArchiveError> { +fn default_config() -> SerializeConfig { + SerializeConfig::new() +} + +fn subset_and_woff2_config() -> SerializeConfig { + SerializeConfig::new() + .with_subset_fonts(true) + .with_woff2_fonts(true) +} + +fn serialize_to_vec(scene: &Scene, config: &SerializeConfig) -> Result, ArchiveError> { let mut buf = Cursor::new(Vec::new()); - SceneArchive::from_scene(scene)?.serialize(&mut buf)?; + SceneArchive::from_scene(scene, config)?.serialize(&mut buf)?; Ok(buf.into_inner()) } @@ -417,7 +375,7 @@ fn archive_deserialize_from_slice(data: &[u8]) -> Result FontData { static ROBOTO_BYTES: &[u8] = include_bytes!("../../../assets/fonts/roboto/Roboto.ttf"); FontData::new(Blob::from(ROBOTO_BYTES.to_vec()), 0) } + +fn build_glyph_scene(font: &FontData) -> Scene { + let mut scene = Scene::new(); + let glyphs = [ + Glyph { + id: 43, + x: 0.0, + y: 0.0, + }, + Glyph { + id: 72, + x: 10.0, + y: 0.0, + }, + Glyph { + id: 79, + x: 20.0, + y: 0.0, + }, + ]; + scene.draw_glyphs( + font, + 16.0, + false, + &[], + Fill::NonZero, + Color::from_rgb8(0, 0, 0), + 1.0, + Affine::translate((10.0, 50.0)), + None, + glyphs.into_iter(), + ); + scene +} + +fn assert_glyph_run_preserved(restored: &Scene) { + assert_eq!(restored.commands.len(), 1); + + match &restored.commands[0] { + RenderCommand::GlyphRun(glyph_run) => { + assert_eq!(glyph_run.font_size, 16.0); + assert_eq!(glyph_run.hint, false); + assert_eq!(glyph_run.brush_alpha, 1.0); + assert_eq!(glyph_run.transform, Affine::translate((10.0, 50.0))); + assert_eq!(glyph_run.glyph_transform, None); + 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:?}"), + } +} diff --git a/examples/serialize/Cargo.toml b/examples/serialize/Cargo.toml index e81cbde..09af45c 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, features = ["subsetting", "woff2"] } +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 68f288f..1fba673 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -6,7 +6,7 @@ use std::path::Path; use anyrender::recording::Scene; use anyrender::{Glyph, PaintScene, render_to_buffer}; -use anyrender_serialize::SceneArchive; +use anyrender_serialize::{SceneArchive, SerializeConfig}; use anyrender_vello_cpu::VelloCpuImageRenderer; use image::{ImageBuffer, RgbaImage}; use kurbo::{Affine, Circle, Point, Rect, RoundedRect, Stroke}; @@ -34,7 +34,10 @@ fn main() { let archive_path = Path::new(OUTPUT_DIR).join("demo_scene.anyrender.zip"); let file = File::create(&archive_path).unwrap(); let writer = BufWriter::new(file); - SceneArchive::from_scene(&original_scene) + let config = SerializeConfig::new() + .with_subset_fonts(true) + .with_woff2_fonts(true); + SceneArchive::from_scene(&original_scene, &config) .unwrap() .serialize(writer) .unwrap();