From b592becea6571247f589d2bf4cf664fe123c5802 Mon Sep 17 00:00:00 2001 From: "nico.burns" Date: Tue, 4 Aug 2026 09:15:14 +0000 Subject: [PATCH] Make text flattening lazy Parsing now only performs text layout. Glyph outlines are computed on first access to Text::flattened, or upfront for the whole tree via the new Tree::compute_flattened_text, which shares an ephemeral glyph cache between all text nodes. Text::stroke_bounding_box is now approximated at layout time from the per-glyph ink bounding boxes stored in the font. --- CHANGELOG.md | 14 +++ crates/resvg/src/lib.rs | 7 ++ crates/usvg/src/parser/converter.rs | 96 ------------------ crates/usvg/src/parser/paint_server.rs | 16 +-- crates/usvg/src/parser/text.rs | 3 +- crates/usvg/src/text/flatten.rs | 131 +++++++++++++++++++++++-- crates/usvg/src/text/mod.rs | 110 +++++++++++++++++++-- crates/usvg/src/tree/mod.rs | 39 +++++++- crates/usvg/src/tree/text.rs | 38 ++++++- crates/usvg/src/writer.rs | 108 +++++++++++++++++--- 10 files changed, 418 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f95ecac..426a6c1e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ This changelog also contains important changes in dependencies. ## [Unreleased] +### Added + +- `usvg::Tree::compute_flattened_text`, which flattens all text nodes in the tree + upfront while sharing a glyph cache between them. + +### Changed + +- Text flattening (converting positioned glyphs into paths) is now performed lazily. + Parsing an SVG only performs text layout; outlines are computed on the first access + to `usvg::Text::flattened` or via `usvg::Tree::compute_flattened_text`. +- `usvg::Text::stroke_bounding_box` is now calculated from the per-glyph ink bounding + boxes stored in the font instead of the flattened outlines, which may result in + slightly different (approximate) bounds for non-outline (e.g. color) glyphs. + ## [0.48.1] 2026-08-02 This release has an MSRV of 1.85.0 for `usvg` and `resvg` and the C API. diff --git a/crates/resvg/src/lib.rs b/crates/resvg/src/lib.rs index 79805f51b..c1e8abecb 100644 --- a/crates/resvg/src/lib.rs +++ b/crates/resvg/src/lib.rs @@ -36,6 +36,13 @@ pub fn render( transform: tiny_skia::Transform, pixmap: &mut tiny_skia::PixmapMut, ) { + // Flatten all text nodes upfront so that a glyph cache + // can be shared between them. + #[cfg(feature = "text")] + if tree.has_text_nodes() { + tree.compute_flattened_text(); + } + let max_bbox = max_filter_bbox(pixmap.width(), pixmap.height()); let ctx = render::Context { max_bbox }; diff --git a/crates/usvg/src/parser/converter.rs b/crates/usvg/src/parser/converter.rs index 2908e28d3..45d86a123 100644 --- a/crates/usvg/src/parser/converter.rs +++ b/crates/usvg/src/parser/converter.rs @@ -6,23 +6,15 @@ use std::hash::{Hash, Hasher}; use std::str::FromStr; use std::sync::Arc; -#[cfg(feature = "text")] -use crate::{FontVariation, GlyphId}; #[cfg(feature = "text")] use fontdb::Database; -#[cfg(feature = "text")] -use fontdb::ID; use svgtypes::{Length, LengthUnit as Unit, PaintOrderKind, TransformOrigin}; use tiny_skia_path::PathBuilder; use super::svgtree::{self, AId, EId, FromValue, SvgNode}; use super::units::{self, convert_length}; use super::{Error, Options, marker}; -#[cfg(feature = "text")] -use crate::flatten::BitmapImage; use crate::parser::paint_server::process_paint; -#[cfg(feature = "text")] -use crate::text::flatten::DatabaseExt; use crate::*; #[derive(Clone)] @@ -49,17 +41,6 @@ pub struct Cache { #[cfg(feature = "text")] pub fontdb: Arc, - #[cfg(feature = "text")] - cache_outline: HashMap<(ID, GlyphId, Vec), Option>, - #[cfg(feature = "text")] - cache_colr: HashMap<(ID, GlyphId, Vec), Option>, - #[cfg(feature = "text")] - cache_svg: HashMap<(ID, GlyphId), Option>, - #[cfg(feature = "text")] - cache_raster: HashMap<(ID, GlyphId), Option>, - #[cfg(feature = "text")] - cache_has_opsz: HashMap, - pub clip_paths: HashMap>, pub masks: HashMap>, pub filters: HashMap>, @@ -76,40 +57,12 @@ pub struct Cache { image_index: usize, } -macro_rules! font_lookup { - ($method_name:ident, $cache_map:ident, $font_variant:ident, $return_type:ty) => { - #[cfg(feature = "text")] - pub(crate) fn $method_name(&mut self, font: ID, glyph: GlyphId) -> Option<$return_type> { - let key = (font, glyph); - match self.$cache_map.get(&key) { - Some(cache_hit) => cache_hit.clone(), - None => { - let lookup = self.fontdb.$font_variant(font, glyph); - self.$cache_map.insert(key, lookup.clone()); - lookup - } - } - } - }; -} - impl Cache { pub(crate) fn new(#[cfg(feature = "text")] fontdb: Arc) -> Self { Self { #[cfg(feature = "text")] fontdb, - #[cfg(feature = "text")] - cache_outline: HashMap::new(), - #[cfg(feature = "text")] - cache_colr: HashMap::new(), - #[cfg(feature = "text")] - cache_svg: HashMap::new(), - #[cfg(feature = "text")] - cache_raster: HashMap::new(), - #[cfg(feature = "text")] - cache_has_opsz: HashMap::new(), - clip_paths: HashMap::new(), masks: HashMap::new(), filters: HashMap::new(), @@ -203,55 +156,6 @@ impl Cache { } } } - - font_lookup!(fontdb_svg, cache_svg, svg, Node); - font_lookup!(fontdb_raster, cache_raster, raster, BitmapImage); - - #[cfg(feature = "text")] - pub(crate) fn fontdb_outline( - &mut self, - font: ID, - glyph: GlyphId, - variations: &[FontVariation], - ) -> Option { - let key = (font, glyph, variations.to_vec()); - match self.cache_outline.get(&key) { - Some(cache_hit) => cache_hit.clone(), - None => { - let lookup = self.fontdb.outline(font, glyph, variations); - self.cache_outline.insert(key, lookup.clone()); - lookup - } - } - } - - #[cfg(feature = "text")] - pub(crate) fn fontdb_colr( - &mut self, - font: ID, - glyph: GlyphId, - variations: &[FontVariation], - ) -> Option { - let key = (font, glyph, variations.to_vec()); - match self.cache_colr.get(&key) { - Some(cache_hit) => cache_hit.clone(), - None => { - let lookup = self.fontdb.colr(font, glyph, variations); - self.cache_colr.insert(key, lookup.clone()); - lookup - } - } - } - - #[cfg(feature = "text")] - pub(crate) fn has_opsz_axis(&mut self, font: ID) -> bool { - if let Some(&cached) = self.cache_has_opsz.get(&font) { - return cached; - } - let has_opsz = self.fontdb.has_opsz_axis(font); - self.cache_has_opsz.insert(font, has_opsz); - has_opsz - } } // TODO: is there a simpler way? diff --git a/crates/usvg/src/parser/paint_server.rs b/crates/usvg/src/parser/paint_server.rs index 3102899b7..6392ada81 100644 --- a/crates/usvg/src/parser/paint_server.rs +++ b/crates/usvg/src/parser/paint_server.rs @@ -664,10 +664,13 @@ fn node_to_user_coordinates( // paint servers. let bbox = text.bounding_box; - // We need to update three things: + // We need to update two things: // 1. The fills/strokes of the original elements in the usvg tree. // 2. The fills/strokes of the layouted elements of the text. - // 3. The fills/strokes of the outlined text. + // + // The outlined text is generated lazily after parsing and clones + // the (already processed) fills/strokes of the layouted elements, + // so it doesn't need to be processed here. // 1. for chunk in &mut text.chunks { @@ -745,15 +748,6 @@ fn node_to_user_coordinates( process_decoration(path); } } - - // 3. - update_paint_servers( - &mut text.flattened, - context_transform, - context_bbox, - Some(bbox), - cache, - ); } } } diff --git a/crates/usvg/src/parser/text.rs b/crates/usvg/src/parser/text.rs index 1cac7a4ad..371a775ed 100644 --- a/crates/usvg/src/parser/text.rs +++ b/crates/usvg/src/parser/text.rs @@ -136,8 +136,9 @@ pub(crate) fn convert( abs_bounding_box: dummy, stroke_bounding_box: dummy, abs_stroke_bounding_box: dummy, - flattened: Box::new(Group::empty()), + flattened: std::sync::OnceLock::new(), layouted: vec![], + fontdb: cache.fontdb.clone(), }; if text::convert(&mut text, &state.opt.font_resolver, cache).is_none() { diff --git a/crates/usvg/src/text/flatten.rs b/crates/usvg/src/text/flatten.rs index 101ca489b..f30f55838 100644 --- a/crates/usvg/src/text/flatten.rs +++ b/crates/usvg/src/text/flatten.rs @@ -1,6 +1,7 @@ // Copyright 2022 the Resvg Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +use std::collections::HashMap; use std::mem; use std::sync::Arc; @@ -29,20 +30,103 @@ fn resolve_rendering_mode(text: &Text) -> ShapeRendering { } } +/// An ephemeral cache used during text flattening to avoid re-extracting +/// the same glyph multiple times. +#[derive(Default)] +pub(crate) struct FlattenCache { + outline: HashMap<(ID, GlyphId, Vec), Option>, + colr: HashMap<(ID, GlyphId, Vec), Option>, + svg: HashMap<(ID, GlyphId), Option>, + raster: HashMap<(ID, GlyphId), Option>, + has_opsz: HashMap, +} + +impl FlattenCache { + fn outline( + &mut self, + fontdb: &Database, + font: ID, + glyph: GlyphId, + variations: &[FontVariation], + ) -> Option { + let key = (font, glyph, variations.to_vec()); + match self.outline.get(&key) { + Some(cache_hit) => cache_hit.clone(), + None => { + let lookup = fontdb.outline(font, glyph, variations); + self.outline.insert(key, lookup.clone()); + lookup + } + } + } + + fn colr( + &mut self, + fontdb: &Database, + font: ID, + glyph: GlyphId, + variations: &[FontVariation], + ) -> Option { + let key = (font, glyph, variations.to_vec()); + match self.colr.get(&key) { + Some(cache_hit) => cache_hit.clone(), + None => { + let lookup = fontdb.colr(font, glyph, variations); + self.colr.insert(key, lookup.clone()); + lookup + } + } + } + + fn svg(&mut self, fontdb: &Database, font: ID, glyph: GlyphId) -> Option { + let key = (font, glyph); + match self.svg.get(&key) { + Some(cache_hit) => cache_hit.clone(), + None => { + let lookup = fontdb.svg(font, glyph); + self.svg.insert(key, lookup.clone()); + lookup + } + } + } + + fn raster(&mut self, fontdb: &Database, font: ID, glyph: GlyphId) -> Option { + let key = (font, glyph); + match self.raster.get(&key) { + Some(cache_hit) => cache_hit.clone(), + None => { + let lookup = fontdb.raster(font, glyph); + self.raster.insert(key, lookup.clone()); + lookup + } + } + } + + fn has_opsz_axis(&mut self, fontdb: &Database, font: ID) -> bool { + if let Some(&cached) = self.has_opsz.get(&font) { + return cached; + } + let has_opsz = fontdb.has_opsz_axis(font); + self.has_opsz.insert(font, has_opsz); + has_opsz + } +} + /// Returns the effective variation settings for a glyph: the span's explicit /// variations plus an automatically computed `opsz` value when /// `font-optical-sizing: auto` is in effect and the font has an `opsz` axis /// that wasn't set explicitly. This matches browser behavior /// (CSS font-optical-sizing: auto). fn effective_variations( - cache: &mut Cache, + cache: &mut FlattenCache, + fontdb: &Database, span: &layout::Span, glyph: &layout::PositionedGlyph, ) -> Vec { let mut variations = span.variations.clone(); if span.font_optical_sizing == crate::FontOpticalSizing::Auto && !variations.iter().any(|v| &v.tag == b"opsz") - && cache.has_opsz_axis(glyph.font) + && cache.has_opsz_axis(fontdb, glyph.font) { variations.push(FontVariation::new(*b"opsz", glyph.font_size())); } @@ -74,7 +158,7 @@ fn push_outline_paths( } } -pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZeroRect)> { +pub(crate) fn flatten(text: &Text, fontdb: &Database, cache: &mut FlattenCache) -> Option { let mut new_children = vec![]; let abs_transform = text.abs_transform; @@ -102,10 +186,10 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ let mut span_builder = tiny_skia_path::PathBuilder::new(); for glyph in &span.positioned_glyphs { - let variations = effective_variations(cache, span, glyph); + let variations = effective_variations(cache, fontdb, span, glyph); // A (best-effort conversion of a) COLR glyph. - if let Some(tree) = cache.fontdb_colr(glyph.font, glyph.id, &variations) { + if let Some(tree) = cache.colr(fontdb, glyph.font, glyph.id, &variations) { let mut group = Group { transform: glyph.colr_transform(), ..Group::empty() @@ -118,7 +202,7 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ new_children.push(Node::Group(Box::new(group))); } // An SVG glyph. Will return the usvg node containing the glyph descriptions. - else if let Some(node) = cache.fontdb_svg(glyph.font, glyph.id) { + else if let Some(node) = cache.svg(fontdb, glyph.font, glyph.id) { push_outline_paths( span, &mut span_builder, @@ -137,7 +221,7 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ new_children.push(Node::Group(Box::new(group))); } // A bitmap glyph. - else if let Some(img) = cache.fontdb_raster(glyph.font, glyph.id) { + else if let Some(img) = cache.raster(fontdb, glyph.font, glyph.id) { push_outline_paths( span, &mut span_builder, @@ -168,7 +252,7 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ new_children.push(Node::Group(Box::new(group))); } else { - let outline = cache.fontdb_outline(glyph.font, glyph.id, &variations); + let outline = cache.outline(fontdb, glyph.font, glyph.id, &variations); if let Some(outline) = outline.and_then(|p| p.transform(glyph.outline_transform())) { @@ -202,8 +286,7 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ } group.calculate_bounding_boxes(); - let stroke_bbox = group.stroke_bounding_box().to_non_zero_rect()?; - Some((group, stroke_bbox)) + Some(group) } #[derive(Default)] @@ -241,6 +324,12 @@ pub(crate) trait DatabaseExt { variations: &[crate::FontVariation], ) -> Option; fn has_opsz_axis(&self, id: ID) -> bool; + fn bounds( + &self, + id: ID, + glyph_id: GlyphId, + variations: &[crate::FontVariation], + ) -> Option; fn raster(&self, id: ID, glyph_id: GlyphId) -> Option; fn svg(&self, id: ID, glyph_id: GlyphId) -> Option; fn colr(&self, id: ID, glyph_id: GlyphId, variations: &[crate::FontVariation]) -> Option; @@ -296,6 +385,28 @@ impl DatabaseExt for Database { .unwrap_or(false) } + fn bounds( + &self, + id: ID, + glyph_id: GlyphId, + variations: &[crate::FontVariation], + ) -> Option { + self.with_face_data(id, |data, face_index| -> Option { + let font = skrifa::FontRef::from_index(data, face_index).ok()?; + let location = font.axes().location( + variations + .iter() + .map(|v| (Tag::from_be_bytes(v.tag), v.value)), + ); + let metrics = font.glyph_metrics( + skrifa::prelude::Size::unscaled(), + LocationRef::from(&location), + ); + let bbox = metrics.bounds(glyph_id.into())?; + tiny_skia_path::Rect::from_ltrb(bbox.x_min, bbox.y_min, bbox.x_max, bbox.y_max) + })? + } + fn raster(&self, id: ID, glyph_id: GlyphId) -> Option { self.with_face_data(id, |data, face_index| -> Option { let font = skrifa::FontRef::from_index(data, face_index).ok()?; diff --git a/crates/usvg/src/text/mod.rs b/crates/usvg/src/text/mod.rs index cdeaa7a8e..c1dfbe49e 100644 --- a/crates/usvg/src/text/mod.rs +++ b/crates/usvg/src/text/mod.rs @@ -7,7 +7,8 @@ use fontdb::{Database, ID}; use svgtypes::FontFamily; use self::layout::DatabaseExt; -use crate::{Cache, Font, FontStretch, FontStyle, Text}; +use crate::tree::BBox; +use crate::{Cache, Font, FontStretch, FontStyle, Group, LineJoin, Node, Text}; pub(crate) mod flatten; mod transform; @@ -209,22 +210,113 @@ impl std::fmt::Debug for FontResolver<'_> { } } -/// Convert a text into its paths. This is done in two steps: -/// 1. We convert the text into glyphs and position them according to the rules specified -/// in the SVG specification. While doing so, we also calculate the text bbox (which -/// is not based on the outlines of a glyph, but instead the glyph metrics as well -/// as decoration spans). -/// 2. We convert all of the positioned glyphs into outlines. +/// Converts a text node into glyphs and positions them according to the rules +/// specified in the SVG specification. While doing so, we also calculate the +/// text bbox (which is not based on the outlines of a glyph, but instead the +/// glyph metrics as well as decoration spans). +/// +/// Note that this only performs the text *layout*. The conversion of the +/// positioned glyphs into outlines ("flattening") is performed lazily, +/// either on demand via [`Text::flattened`](crate::Text::flattened) or +/// upfront for the whole tree via +/// [`Tree::compute_flattened_text`](crate::Tree::compute_flattened_text). pub(crate) fn convert(text: &mut Text, resolver: &FontResolver, cache: &mut Cache) -> Option<()> { let (text_fragments, bbox) = layout::layout_text(text, resolver, &mut cache.fontdb)?; text.layouted = text_fragments; text.bounding_box = bbox.to_rect(); text.abs_bounding_box = bbox.transform(text.abs_transform)?.to_rect(); - let (group, stroke_bbox) = flatten::flatten(text, cache)?; - text.flattened = Box::new(group); + // Take a snapshot of the font database so that lazy flattening has access + // to the same fonts (including ones loaded on demand during layout). + text.fontdb = cache.fontdb.clone(); + + // The stroke bounding box has to be known before flattening, because + // ancestor group bounding boxes are calculated during parsing. + // We approximate it from the per-glyph ink bounding boxes stored in the + // font (no outline extraction required), the layout bounding box and + // the decoration paths. + let stroke_bbox = calculate_stroke_bbox(text).unwrap_or(bbox); text.stroke_bounding_box = stroke_bbox.to_rect(); text.abs_stroke_bounding_box = stroke_bbox.transform(text.abs_transform)?.to_rect(); Some(()) } + +/// Calculates an approximate ink bounding box of a text node, including stroke. +/// +/// The returned bbox is the union of the per-glyph ink bounding boxes stored +/// in the font (expanded by the stroke width when the span is stroked) and +/// the decoration paths' stroke bounding boxes. When the ink bounds of a +/// glyph are not available, the layout (metrics) bounding box is used as a +/// fallback. The result is guaranteed to be at least as large as the ink of +/// the glyph outlines, which is what layer allocation during rendering +/// requires. +fn calculate_stroke_bbox(text: &Text) -> Option { + use self::flatten::DatabaseExt as _; + + type BoundsKey = (ID, GlyphId, Vec); + + let mut bbox = BBox::default(); + let mut bounds_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + + for span in &text.layouted { + // The maximum distance the stroke can extend beyond the path. + // Miter joins can extend up to `stroke-miterlimit * stroke-width / 2` + // beyond the joint point; other joins at most `stroke-width / 2`. + let stroke_expansion = span.stroke.as_ref().map(|stroke| { + let half_width = stroke.width.get() / 2.0; + match stroke.linejoin { + LineJoin::Miter | LineJoin::MiterClip => { + half_width * stroke.miterlimit.get().max(1.0) + } + LineJoin::Round | LineJoin::Bevel => half_width, + } + }); + + for glyph in &span.positioned_glyphs { + let bounds = *bounds_cache + .entry((glyph.font, glyph.id, span.variations.clone())) + .or_insert_with(|| text.fontdb.bounds(glyph.font, glyph.id, &span.variations)); + + // Glyph ink bounds are in font units with a Y-up orientation, + // just like glyph outlines, so the outline transform applies. + // When the ink bounds are not available (e.g. for glyphs + // without a `glyf`/`CFF` outline), fall back to the layout + // (metrics) bounding box of the whole text. + let rect = bounds + .and_then(|bounds| bounds.transform(glyph.outline_transform())) + .unwrap_or(text.bounding_box); + + let rect = match stroke_expansion { + Some(delta) => rect.outset(delta, delta).unwrap_or(rect), + None => rect, + }; + bbox = bbox.expand(rect); + } + + for path in [&span.overline, &span.underline, &span.line_through] + .into_iter() + .flatten() + { + bbox = bbox.expand(path.stroke_bounding_box()); + } + } + + bbox.to_non_zero_rect() +} + +/// Flattens all text nodes in a group, recursively, sharing a single glyph cache. +pub(crate) fn flatten_group(parent: &Group, cache: &mut flatten::FlattenCache) { + for node in &parent.children { + match node { + Node::Text(text) => { + text.flatten_with_cache(cache); + } + Node::Group(group) => flatten_group(group, cache), + _ => {} + } + + node.subroots(|subroot| flatten_group(subroot, cache)); + } +} diff --git a/crates/usvg/src/tree/mod.rs b/crates/usvg/src/tree/mod.rs index 6b204f527..73ddf7064 100644 --- a/crates/usvg/src/tree/mod.rs +++ b/crates/usvg/src/tree/mod.rs @@ -1685,6 +1685,24 @@ impl Tree { &self.fontdb } + /// Converts all text nodes in the tree into paths. + /// + /// Text flattening is performed lazily: parsing an SVG only lays the text + /// out, and the outlines are computed on the first access to + /// [`Text::flattened`]. This method computes the flattened representation + /// of every text node in the tree (including ones inside clip paths, + /// masks, patterns and filters) upfront, while sharing a glyph cache + /// between all of them, which is faster than flattening each text node + /// separately when glyphs are reused across text nodes. + /// + /// Calling this method (or [`Text::flattened`]) more than once is cheap: + /// already-flattened text nodes are skipped. + #[cfg(feature = "text")] + pub fn compute_flattened_text(&self) { + let mut cache = crate::text::flatten::FlattenCache::default(); + crate::text::flatten_group(&self.root, &mut cache); + } + pub(crate) fn collect_paint_servers(&mut self) { loop_over_paint_servers(&self.root, &mut |paint| match paint { Paint::Color(_) => {} @@ -1757,7 +1775,7 @@ fn has_text_nodes(root: &Group) -> bool { false } -fn loop_over_paint_servers(parent: &Group, f: &mut dyn FnMut(&Paint)) { +pub(crate) fn loop_over_paint_servers(parent: &Group, f: &mut dyn FnMut(&Paint)) { fn push(paint: Option<&Paint>, f: &mut dyn FnMut(&Paint)) { if let Some(paint) = paint { f(paint); @@ -1772,7 +1790,24 @@ fn loop_over_paint_servers(parent: &Group, f: &mut dyn FnMut(&Paint)) { push(path.stroke.as_ref().map(|f| &f.paint), f); } Node::Image(_) => {} - // Flattened text would be used instead. + // Flattened text shares the fills/strokes of the layouted spans, + // but is generated lazily, so collect the span paints instead. + #[cfg(feature = "text")] + Node::Text(text) => { + for span in &text.layouted { + push(span.fill.as_ref().map(|f| &f.paint), f); + push(span.stroke.as_ref().map(|f| &f.paint), f); + + for path in [&span.underline, &span.overline, &span.line_through] + .into_iter() + .flatten() + { + push(path.fill.as_ref().map(|f| &f.paint), f); + push(path.stroke.as_ref().map(|f| &f.paint), f); + } + } + } + #[cfg(not(feature = "text"))] Node::Text(_) => {} } diff --git a/crates/usvg/src/tree/text.rs b/crates/usvg/src/tree/text.rs index c1739b901..100be08d9 100644 --- a/crates/usvg/src/tree/text.rs +++ b/crates/usvg/src/tree/text.rs @@ -1,7 +1,7 @@ // Copyright 2018 the Resvg Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use strict_num::NonZeroPositiveF32; pub use svgtypes::FontFamily; @@ -586,9 +586,11 @@ pub struct Text { pub(crate) abs_bounding_box: Rect, pub(crate) stroke_bounding_box: Rect, pub(crate) abs_stroke_bounding_box: Rect, - pub(crate) flattened: Box, + pub(crate) flattened: OnceLock>, #[cfg(feature = "text")] pub(crate) layouted: Vec, + #[cfg(feature = "text")] + pub(crate) fontdb: Arc, } impl Text { @@ -687,6 +689,12 @@ impl Text { /// Text converted into paths, ready to render. /// + /// The conversion is performed lazily: the first call to this method + /// converts the positioned glyphs into paths and caches the result. + /// To flatten all text nodes in a tree at once, while sharing a glyph + /// cache between them, use + /// [`Tree::compute_flattened_text`](crate::Tree::compute_flattened_text). + /// /// Note that this is only a /// "best-effort" attempt: The text will be converted into group/paths/image /// primitives, so that they can be rendered with the existing infrastructure. @@ -702,7 +710,27 @@ impl Text { /// If the two above are not acceptable, then you will need to implement your own /// glyph rendering logic based on the layouted glyphs (see the `layouted` method). pub fn flattened(&self) -> &Group { - &self.flattened + #[cfg(feature = "text")] + { + self.flatten_with_cache(&mut crate::text::flatten::FlattenCache::default()) + } + #[cfg(not(feature = "text"))] + { + self.flattened.get_or_init(|| Box::new(Group::empty())) + } + } + + #[cfg(feature = "text")] + pub(crate) fn flatten_with_cache( + &self, + cache: &mut crate::text::flatten::FlattenCache, + ) -> &Group { + self.flattened.get_or_init(|| { + Box::new( + crate::text::flatten::flatten(self, &self.fontdb, cache) + .unwrap_or_else(Group::empty), + ) + }) } /// The positioned glyphs and decoration spans of the text. @@ -716,6 +744,8 @@ impl Text { } pub(crate) fn subroots(&self, f: &mut dyn FnMut(&Group)) { - f(&self.flattened); + if let Some(flattened) = self.flattened.get() { + f(flattened); + } } } diff --git a/crates/usvg/src/writer.rs b/crates/usvg/src/writer.rs index e5dc9ddc0..6a1f5565c 100644 --- a/crates/usvg/src/writer.rs +++ b/crates/usvg/src/writer.rs @@ -3,6 +3,7 @@ use std::fmt::Display; use std::io::Write; +use std::sync::Arc; use svgtypes::{FontFamily, parse_font_families}; use xmlwriter::XmlWriter; @@ -139,6 +140,19 @@ impl Default for WriteOptions { } pub(crate) fn convert(tree: &Tree, opt: &WriteOptions) -> String { + // Text nodes are written as paths (unless `preserve_text` is set), + // so all text nodes have to be flattened first. + #[cfg(feature = "text")] + if !opt.preserve_text && tree.has_text_nodes() { + tree.compute_flattened_text(); + } + + // Since text is flattened lazily, the flattened subtrees can reference + // paint servers, clip paths, masks and filters that are not present in + // the lists stored in the tree (which are collected during parsing). + // Therefore we re-collect them here. + let defs = Defs::collect(tree); + let mut xml = XmlWriter::new(xmlwriter::Options { use_single_quote: opt.use_single_quote, indent: opt.indent, @@ -154,8 +168,8 @@ pub(crate) fn convert(tree: &Tree, opt: &WriteOptions) -> String { } let has_text_paths = has_text_paths(&tree.root); - if tree.has_defs_nodes() || has_text_paths { - write_defs(tree, opt, &mut xml, has_text_paths); + if !defs.is_empty() || has_text_paths { + write_defs(tree, &defs, opt, &mut xml, has_text_paths); } write_elements(&tree.root, false, opt, &mut xml); @@ -163,9 +177,75 @@ pub(crate) fn convert(tree: &Tree, opt: &WriteOptions) -> String { xml.end_document() } -fn write_filters(tree: &Tree, opt: &WriteOptions, xml: &mut XmlWriter) { +struct Defs { + linear_gradients: Vec>, + radial_gradients: Vec>, + patterns: Vec>, + clip_paths: Vec>, + masks: Vec>, + filters: Vec>, +} + +impl Defs { + fn collect(tree: &Tree) -> Self { + // Start from the lists stored in the tree to preserve their order, + // then append anything that only exists in flattened text subtrees. + let mut defs = Defs { + linear_gradients: tree.linear_gradients().to_vec(), + radial_gradients: tree.radial_gradients().to_vec(), + patterns: tree.patterns().to_vec(), + clip_paths: tree.clip_paths().to_vec(), + masks: tree.masks().to_vec(), + filters: tree.filters().to_vec(), + }; + + crate::tree::loop_over_paint_servers(tree.root(), &mut |paint| match paint { + Paint::Color(_) => {} + Paint::LinearGradient(lg) => { + if !defs + .linear_gradients + .iter() + .any(|other| Arc::ptr_eq(lg, other)) + { + defs.linear_gradients.push(lg.clone()); + } + } + Paint::RadialGradient(rg) => { + if !defs + .radial_gradients + .iter() + .any(|other| Arc::ptr_eq(rg, other)) + { + defs.radial_gradients.push(rg.clone()); + } + } + Paint::Pattern(patt) => { + if !defs.patterns.iter().any(|other| Arc::ptr_eq(patt, other)) { + defs.patterns.push(patt.clone()); + } + } + }); + + tree.root().collect_clip_paths(&mut defs.clip_paths); + tree.root().collect_masks(&mut defs.masks); + tree.root().collect_filters(&mut defs.filters); + + defs + } + + fn is_empty(&self) -> bool { + self.linear_gradients.is_empty() + && self.radial_gradients.is_empty() + && self.patterns.is_empty() + && self.clip_paths.is_empty() + && self.masks.is_empty() + && self.filters.is_empty() + } +} + +fn write_filters(defs: &Defs, opt: &WriteOptions, xml: &mut XmlWriter) { let mut written_fe_image_nodes: Vec = Vec::new(); - for filter in tree.filters() { + for filter in &defs.filters { for fe in &filter.primitives { if let filter::Kind::Image(ref img) = fe.kind { if let Some(child) = img.root().children.first() { @@ -488,9 +568,15 @@ fn write_filters(tree: &Tree, opt: &WriteOptions, xml: &mut XmlWriter) { } } -fn write_defs(tree: &Tree, opt: &WriteOptions, xml: &mut XmlWriter, write_text_paths: bool) { +fn write_defs( + tree: &Tree, + defs: &Defs, + opt: &WriteOptions, + xml: &mut XmlWriter, + write_text_paths: bool, +) { xml.start_svg_element(EId::Defs); - for lg in tree.linear_gradients() { + for lg in &defs.linear_gradients { xml.start_svg_element(EId::LinearGradient); xml.write_id_attribute(lg.id(), opt); xml.write_svg_attribute(AId::X1, &lg.x1); @@ -501,7 +587,7 @@ fn write_defs(tree: &Tree, opt: &WriteOptions, xml: &mut XmlWriter, write_text_p xml.end_element(); } - for rg in tree.radial_gradients() { + for rg in &defs.radial_gradients { xml.start_svg_element(EId::RadialGradient); xml.write_id_attribute(rg.id(), opt); xml.write_svg_attribute(AId::Cx, &rg.cx); @@ -513,7 +599,7 @@ fn write_defs(tree: &Tree, opt: &WriteOptions, xml: &mut XmlWriter, write_text_p xml.end_element(); } - for pattern in tree.patterns() { + for pattern in &defs.patterns { xml.start_svg_element(EId::Pattern); xml.write_id_attribute(pattern.id(), opt); xml.write_rect_attrs(pattern.rect); @@ -534,9 +620,9 @@ fn write_defs(tree: &Tree, opt: &WriteOptions, xml: &mut XmlWriter, write_text_p write_text_path_paths(&tree.root, opt, xml); } - write_filters(tree, opt, xml); + write_filters(defs, opt, xml); - for clip in tree.clip_paths() { + for clip in &defs.clip_paths { xml.start_svg_element(EId::ClipPath); xml.write_id_attribute(clip.id(), opt); xml.write_transform(AId::Transform, clip.transform, opt); @@ -550,7 +636,7 @@ fn write_defs(tree: &Tree, opt: &WriteOptions, xml: &mut XmlWriter, write_text_p xml.end_element(); } - for mask in tree.masks() { + for mask in &defs.masks { xml.start_svg_element(EId::Mask); xml.write_id_attribute(mask.id(), opt); if mask.kind == MaskType::Alpha {