Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 3 additions & 9 deletions crates/anyrender_serialize/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
86 changes: 32 additions & 54 deletions crates/anyrender_serialize/src/font_writer.rs
Original file line number Diff line number Diff line change
@@ -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<u8>,
/// SHA-256 hex hash of `stored_data`.
pub hash: String,
Expand All @@ -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<u64, ResourceId>,

fonts: Vec<FontData>,

#[cfg(feature = "subsetting")]
glyph_ids: Vec<HashSet<u32>>,
}

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;
Expand All @@ -74,59 +67,44 @@ 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`].
///
/// 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<Item = Result<ProcessedFont, ArchiveError>> {
#[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<GlyphId> = IntSet::empty();
for &gid in font_glyph_ids {
for &gid in glyph_ids {
input_gids.insert(GlyphId::new(gid));
}

Expand All @@ -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"
Expand Down
38 changes: 31 additions & 7 deletions crates/anyrender_serialize/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -381,9 +381,9 @@ fn convert_to_rgba(image: &ImageData) -> Result<Blob<u8>, ArchiveError> {

impl SceneArchive {
/// Create a new SceneArchive from a recorded Scene.
pub fn from_scene(scene: &Scene) -> Result<Self, ArchiveError> {
pub fn from_scene(scene: &Scene, config: &SerializeConfig) -> Result<Self, ArchiveError> {
let mut manifest = ResourceManifest::new(scene.tolerance);
let mut collector = ResourceCollector::new();
let mut collector = ResourceCollector::new(config.clone());

let commands: Vec<_> = scene
.commands
Expand Down Expand Up @@ -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),
Expand Down
Loading