diff --git a/Cargo.lock b/Cargo.lock index e42ad3e..b33f1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2075,6 +2075,7 @@ dependencies = [ "ureq", "url", "usvg", + "wuff", ] [[package]] @@ -4435,6 +4436,17 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wuff" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f625c6894838ab68e8c1c6e0f728e67363c9d4391cfa82333e58e6e30bc3a627" +dependencies = [ + "brotli-decompressor", + "bytes", + "flate2", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/crates/obscura-render/Cargo.toml b/crates/obscura-render/Cargo.toml index 256feb5..2e063ae 100644 --- a/crates/obscura-render/Cargo.toml +++ b/crates/obscura-render/Cargo.toml @@ -11,7 +11,7 @@ default = [] # Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text # shaping + line breaking, ab_glyph fallback) and images. # Off by default; the layout core (getBoundingClientRect etc.) works without it. -paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url"] +paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"] [dependencies] taffy = { workspace = true } @@ -32,6 +32,7 @@ resvg = { version = "0.47", default-features = false, features = ["text", "raste usvg = { version = "0.47", default-features = false, optional = true } url = { workspace = true, optional = true } cssparser = "0.34" +wuff = { version = "0.2.7", optional = true } [lib] path = "src/lib.rs" diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index baaabab..4277f82 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -392,6 +392,17 @@ pub fn layout_dom_with_images( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, +) -> DomLayout { + layout_dom_with_resources(tree, viewport, intrinsic, &[]) +} + +/// Like [`layout_dom_with_images`], with decoded OpenType web-font data loaded +/// into the shaping database for this render pass. +pub fn layout_dom_with_resources( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[Vec], ) -> DomLayout { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); @@ -449,7 +460,7 @@ pub fn layout_dom_with_images( let mut taffy_tree: TaffyTree = TaffyTree::new(); let mut id_map: HashMap = HashMap::new(); let mut words: HashMap = HashMap::new(); - let mut engine = crate::inline::TextEngine::new(); + let mut engine = crate::inline::TextEngine::new_with_fonts(fonts); let mut ifc_items = IfcRegistry::default(); // The document node itself is not an element; lay out from the first diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 790d155..e85644a 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -12,7 +12,7 @@ //! Fonts are loaded from embedded bytes only, never the OS, so layout is //! byte-for-byte deterministic across hosts (the whole engine's guarantee). -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap}; @@ -77,6 +77,24 @@ fn resolve_font_family(fam: Option<&str>) -> &'static str { } FAMILY } + +fn resolve_loaded_font_family( + fam: Option<&str>, + loaded: &HashMap>, +) -> Arc { + if let Some(stack) = fam { + for token in stack.split(',') { + let name = token + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .trim(); + if let Some(family) = loaded.get(&name.to_ascii_lowercase()) { + return Arc::clone(family); + } + } + } + Arc::from(resolve_font_family(fam)) +} /// Resolve `line-height: normal` from the selected face's horizontal header. /// /// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and @@ -148,6 +166,7 @@ pub struct InlineItem { /// [`crate::DomLayout`] so paint can rasterize the shaped glyphs. pub struct TextEngine { font_system: FontSystem, + loaded_families: HashMap>, swash: SwashCache, items: Vec, replaced: Vec, @@ -215,6 +234,10 @@ impl Default for TextEngine { impl TextEngine { pub fn new() -> Self { + Self::new_with_fonts(&[]) + } + + pub fn new_with_fonts(fonts: &[Vec]) -> Self { // Build a database from our four embedded faces only. Never call // load_system_fonts: a host's font set would make layout differ // machine to machine and add a multi-millisecond startup scan. @@ -227,10 +250,24 @@ impl TextEngine { ] { db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))); } + for bytes in fonts { + db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new( + bytes.clone(), + ))); + } + let mut loaded_families = HashMap::new(); + for face in db.faces() { + for (name, _) in &face.families { + loaded_families + .entry(name.to_ascii_lowercase()) + .or_insert_with(|| Arc::from(name.as_str())); + } + } db.set_sans_serif_family(FAMILY); let font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db); TextEngine { font_system, + loaded_families, swash: SwashCache::new(), items: Vec::new(), replaced: Vec::new(), @@ -256,9 +293,21 @@ impl TextEngine { } let base = styles.get(&id)?; let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() }; - let ctx = base_span_ctx(base, &mut collector); + let ctx = base_span_ctx( + base, + resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families), + &mut collector, + ); let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); - collect_spans(tree, id, styles, ctx, &mut spans, &mut collector); + collect_spans( + tree, + id, + styles, + ctx, + &mut spans, + &mut collector, + &self.loaded_families, + ); self.push_shaped_item(base, spans, collector.clip_fills) } @@ -287,10 +336,22 @@ impl TextEngine { } let base = styles.get(&parent)?; let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() }; - let ctx = base_span_ctx(base, &mut collector); + let ctx = base_span_ctx( + base, + resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families), + &mut collector, + ); let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); for &cid in run { - collect_node_spans(tree, cid, styles, ctx, &mut spans, &mut collector); + collect_node_spans( + tree, + cid, + styles, + ctx.clone(), + &mut spans, + &mut collector, + &self.loaded_families, + ); } self.push_shaped_item(base, spans, collector.clip_fills) } @@ -452,13 +513,13 @@ struct SpanAttrs { italic: bool, underline: bool, color: [u8; 4], - family: &'static str, + family: Arc, clip_fill: Option, } impl SpanAttrs { fn to_attrs(&self) -> Attrs<'_> { - let mut a = Attrs::new().family(Family::Name(self.family)); + let mut a = Attrs::new().family(Family::Name(self.family.as_ref())); a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL }); a = a.style(if self.italic { Style::Italic } else { Style::Normal }); // Clip-text glyphs must be shaped with an opaque fill so their coverage @@ -473,14 +534,14 @@ impl SpanAttrs { } /// Inherited inline context threaded down the subtree while collecting spans. -#[derive(Clone, Copy)] +#[derive(Clone)] struct SpanCtx { color: [u8; 4], bold: bool, italic: bool, underline: bool, transform: TextTransform, - family: &'static str, + family: Arc, clip_fill: Option, } @@ -502,7 +563,11 @@ struct Collector { /// shape the glyphs in opaque white so their coverage renders, then recolor /// them from the background at paint time; otherwise transparent text stays /// transparent (and invisible), unchanged. -fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx { +fn base_span_ctx( + base: &LayoutStyle, + family: Arc, + collector: &mut Collector, +) -> SpanCtx { let clip_fill = clip_text_fill(base).map(|fill| { let index = collector.clip_fills.len(); collector.clip_fills.push(fill); @@ -514,7 +579,7 @@ fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx { italic: base.font_style_italic.unwrap_or(false), underline: base.underline.unwrap_or(false), transform: base.text_transform.unwrap_or(TextTransform::None), - family: resolve_font_family(base.font_family.as_deref()), + family, clip_fill, } } @@ -526,9 +591,18 @@ fn collect_spans( ctx: SpanCtx, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector, + loaded_families: &HashMap>, ) { for cid in tree.children(id) { - collect_node_spans(tree, cid, styles, ctx, out, c); + collect_node_spans( + tree, + cid, + styles, + ctx.clone(), + out, + c, + loaded_families, + ); } } @@ -543,6 +617,7 @@ fn collect_node_spans( ctx: SpanCtx, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector, + loaded_families: &HashMap>, ) { let Some(node) = tree.get_node(cid) else { return }; match &node.data { @@ -552,7 +627,7 @@ fn collect_node_spans( italic: ctx.italic, underline: ctx.underline, color: ctx.color, - family: ctx.family, + family: Arc::clone(&ctx.family), clip_fill: ctx.clip_fill, }; push_text(contents, ctx.transform, &attrs, out, c); @@ -569,7 +644,7 @@ fn collect_node_spans( italic: ctx.italic, underline: ctx.underline, color: ctx.color, - family: ctx.family, + family: Arc::clone(&ctx.family), clip_fill: ctx.clip_fill, })); c.last_was_space = true; @@ -597,11 +672,21 @@ fn collect_node_spans( transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform), family: style .and_then(|s| s.font_family.as_deref()) - .map(|f| resolve_font_family(Some(f))) - .unwrap_or(ctx.family), + .map(|family| { + resolve_loaded_font_family(Some(family), loaded_families) + }) + .unwrap_or_else(|| Arc::clone(&ctx.family)), clip_fill, }; - collect_spans(tree, cid, styles, child, out, c); + collect_spans( + tree, + cid, + styles, + child, + out, + c, + loaded_families, + ); } } } diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 67ef0d4..0761fa6 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -20,7 +20,7 @@ pub mod style; pub use style::compute_style; pub mod dom; -pub use dom::{layout_dom, layout_dom_with_images, DomLayout}; +pub use dom::{layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout}; #[cfg(feature = "paint")] mod paint; diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 100bafd..9b654df 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -11,7 +11,7 @@ use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); -use crate::layout_dom_with_images; +use crate::layout_dom_with_resources; /// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or /// None if the viewport is zero-sized. `base_url`, when given, resolves the @@ -33,7 +33,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // and never paint). This seeds the same cache the paint pass reads, so // each URL is still fetched at most once. let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); - let mut laid = layout_dom_with_images(tree, viewport, &intrinsic); + let fonts = collect_web_fonts(tree, base_url); + let mut laid = layout_dom_with_resources(tree, viewport, &intrinsic, &fonts); // Nodes that live inside an inline `` we rasterized as one document; // their painting is owned by that raster, so they are skipped in both the // box/text loop below and the inline-formatting loop after it (an svg @@ -757,13 +758,12 @@ fn fetch_bytes( base_url: Option<&str>, cache: &mut std::collections::HashMap>>, ) -> Option> { - if let Some(rest) = src.strip_prefix("data:image/") { + if let Some(rest) = src.strip_prefix("data:") { let comma_idx = rest.find(',')?; let (meta, data) = (&rest[..comma_idx], &rest[comma_idx + 1..]); - // Inline SVGs are very commonly authored as data:image/svg+xml;utf8, - // (or with no encoding label at all, which is equivalent): plain, - // percent-escaped text, not base64. Only decode as base64 when the - // URI actually says so. + // Data-backed SVGs and web fonts may be base64 or percent-escaped. + // Decode from the encoding label rather than assuming every data URI + // is base64. return if meta.contains("base64") { use base64::Engine; base64::engine::general_purpose::STANDARD.decode(data).ok() @@ -805,6 +805,257 @@ fn fetch_bytes( .clone() } +/// Fetch the Latin/ASCII face from each authored `@font-face` rule and decode +/// WOFF/WOFF2 into the sfnt bytes consumed by fontdb/cosmic-text. Unicode-range +/// filtering is load-bearing for performance: generated font packages commonly +/// emit six or seven script subsets per face, while an English page needs only +/// the subset containing ASCII. +fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec> { + let mut cache = std::collections::HashMap::new(); + let mut seen = std::collections::HashSet::new(); + let mut fonts = Vec::new(); + + // Critical web fonts are normally preloaded from the document with a URL + // already resolved relative to the HTML. Prefer that browser-authored list: + // CSS gathered from external stylesheets is flattened into