diff --git a/crates/conversion/vec2svg/src/backend/mod.rs b/crates/conversion/vec2svg/src/backend/mod.rs index 01d992ec3..4473d04b7 100644 --- a/crates/conversion/vec2svg/src/backend/mod.rs +++ b/crates/conversion/vec2svg/src/backend/mod.rs @@ -27,6 +27,10 @@ pub trait NotifyPaint { fn notify_paint(&mut self, url_ref: ImmutStr) -> (u8, Fingerprint, Option); } +pub trait NotifyImage { + fn notify_image(&mut self, image: &Arc) -> Fingerprint; +} + pub trait DynExportFeature { fn should_render_text_element(&self) -> bool; @@ -434,6 +438,7 @@ impl TransformContext for SvgTextBuilder { impl< 'm, C: NotifyPaint + + NotifyImage + RenderVm<'m, Resultant = Arc> + FontIndice<'m> + DynExportFeature, @@ -539,8 +544,21 @@ impl< self.content.push(content); } - fn render_image(&mut self, _ctx: &mut C, image_item: &ir::ImageItem) { - self.content.push(render_image_item(image_item)) + fn render_image(&mut self, ctx: &mut C, image_item: &ir::ImageItem) { + if matches!( + image_item.image.format.as_ref(), + "png" | "jpeg" | "gif" | "webp" + ) { + let image_id = ctx.notify_image(&image_item.image); + self.content.push(render_image_item(image_item, image_id)); + } else { + self.content.push(SvgText::Plain(render_image( + &image_item.image, + image_item.size, + true, + "", + ))); + } } fn render_content_hint(&mut self, _ctx: &mut C, ch: char) { @@ -762,8 +780,30 @@ fn render_path( /// Render a [`ir::ImageItem`] into svg text. #[comemo::memoize] -fn render_image_item(img: &ir::ImageItem) -> SvgText { - SvgText::Plain(render_image(&img.image, img.size, true, "")) +fn render_image_item(img: &ir::ImageItem, image_id: Fingerprint) -> SvgText { + let styles = image_attrs(&img.image); + let w = img.size.x.0; + let h = img.size.y.0; + let id = image_id.as_svg_id("i"); + SvgText::Plain(format!( + r##""##, + )) +} + +fn image_attrs(image: &ir::Image) -> String { + image + .attrs + .iter() + .map(|attr| match attr { + ir::ImageAttr::Alt(alt) => { + format!(r#" alt="{}""#, escape::escape_str::(alt)) + } + ir::ImageAttr::ImageRendering(rendering) => { + format!(r#" image-rendering="{rendering}""#) + } + }) + .collect::>() + .join(" ") } /// Render a raster or SVG image into svg text. @@ -774,13 +814,7 @@ fn render_image_item(img: &ir::ImageItem) -> SvgText { pub fn render_image(image: &ir::Image, size: Size, is_image_elem: bool, style: &str) -> String { let image_url = embed_as_image_url(image).unwrap(); - let styles = image.attrs.iter().map(|attr| match attr { - ir::ImageAttr::Alt(alt) => { - format!(r#" alt="{}""#, escape::escape_str::(alt)) - } - ir::ImageAttr::ImageRendering(rendering) => format!(r#" image-rendering="{rendering}""#), - }); - let styles = styles.collect::>().join(" "); + let styles = image_attrs(image); let w = size.x.0; let h = size.y.0; @@ -803,6 +837,14 @@ fn embed_as_image_url(image: &ir::Image) -> Option { Some(data) } +pub(crate) fn render_image_def(id: Fingerprint, image: &ir::Image) -> SvgText { + let image_url = embed_as_image_url(image).unwrap(); + SvgText::Plain(format!( + r#""#, + id.as_svg_id("i"), + )) +} + fn glyph_aspect_ratio(font: &FontItem, glyph: u32) -> Option { let (width, height) = match font.get_glyph(glyph)?.as_ref() { ir::FlatGlyphItem::Outline(outline) => { diff --git a/crates/conversion/vec2svg/src/frontend/context.rs b/crates/conversion/vec2svg/src/frontend/context.rs index 19d96908a..7181003a6 100644 --- a/crates/conversion/vec2svg/src/frontend/context.rs +++ b/crates/conversion/vec2svg/src/frontend/context.rs @@ -4,7 +4,7 @@ use std::{ }; use reflexo::{ - hash::{item_hash128, Fingerprint, FingerprintBuilder}, + hash::{hash128, item_hash128, Fingerprint, FingerprintBuilder}, vector::{ ir::{ self, FontIndice, FontRef, GroupRef, ImmutStr, Module, PathItem, Scalar, TextItem, @@ -15,9 +15,11 @@ use reflexo::{ }; use reflexo_typst2vec::ir::Axes; -use super::{GradientDefMap, GradientDefRef}; +use super::{GradientDefMap, GradientDefRef, ImageDefMap}; use crate::{ - backend::{BuildClipPath, DynExportFeature, NotifyPaint, SvgTextBuilder, SvgTextNode}, + backend::{ + BuildClipPath, DynExportFeature, NotifyImage, NotifyPaint, SvgTextBuilder, SvgTextNode, + }, ExportFeature, }; @@ -53,6 +55,7 @@ pub struct RenderContext<'m, 't, Feat: ExportFeature> { pub(crate) gradients: &'t mut GradientDefMap, /// Stores the patterns used in the document. pub(crate) patterns: &'t mut PaintFillMap, + pub(crate) images: &'t mut ImageDefMap, /// See [`ExportFeature`]. pub should_render_text_element: bool, @@ -177,6 +180,14 @@ impl NotifyPaint for RenderContext<'_, '_, Feat> { } } +impl NotifyImage for RenderContext<'_, '_, Feat> { + fn notify_image(&mut self, image: &Arc) -> Fingerprint { + let id = Fingerprint::from_u128(hash128(&(image.format.as_ref(), image.data.as_ref()))); + self.images.entry(id).or_insert_with(|| image.clone()); + id + } +} + /// Example of how to implement a FlatRenderVm. impl<'m, Feat: ExportFeature> RenderVm<'m> for RenderContext<'m, '_, Feat> { // type Resultant = String; diff --git a/crates/conversion/vec2svg/src/frontend/incremental.rs b/crates/conversion/vec2svg/src/frontend/incremental.rs index a09bd38cb..12dd8450f 100644 --- a/crates/conversion/vec2svg/src/frontend/incremental.rs +++ b/crates/conversion/vec2svg/src/frontend/incremental.rs @@ -12,7 +12,7 @@ use reflexo_typst2vec::{ }; use crate::{ - backend::{SvgText, SvgTextNode}, + backend::{render_image_def, SvgText, SvgTextNode}, ExportFeature, SvgExporter, SvgTask, }; @@ -230,6 +230,20 @@ impl IncrSvgDocClient { IncrExporter::patterns(patterns.into_iter(), &mut svg); svg.push("".into()); + if !t.images.is_empty() { + svg.push(r#""#.into()); + let mut images = std::mem::take(&mut t.images) + .into_iter() + .collect::>(); + images.sort_by_key(|(id, _)| *id); + svg.extend( + images + .into_iter() + .map(|(id, image)| render_image_def(id, &image)), + ); + svg.push("".into()); + } + IncrExporter::style_defs(t.style_defs, &mut svg); // body diff --git a/crates/conversion/vec2svg/src/frontend/mod.rs b/crates/conversion/vec2svg/src/frontend/mod.rs index ec0b983d6..7d291b014 100644 --- a/crates/conversion/vec2svg/src/frontend/mod.rs +++ b/crates/conversion/vec2svg/src/frontend/mod.rs @@ -6,7 +6,12 @@ pub(crate) mod incremental; pub use dynamic_layout::DynamicLayoutSvgExporter; pub use incremental::{IncrSvgDocClient, IncrSvgDocServer, IncrementalRenderContext}; -use std::{collections::HashSet, f32::consts::TAU, fmt::Write, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + f32::consts::TAU, + fmt::Write, + sync::Arc, +}; use reflexo::hash::{item_hash128, Fingerprint, FingerprintBuilder}; use reflexo_typst2vec::{ @@ -24,7 +29,7 @@ use typst::{ }; use crate::{ - backend::{SvgGlyphBuilder, SvgText, SvgTextNode}, + backend::{render_image_def, SvgGlyphBuilder, SvgText, SvgTextNode}, ExportFeature, SvgDataSelection, }; use context::{PaintFillMap, RenderContext, StyleDefMap}; @@ -57,6 +62,7 @@ pub struct GradientDefRef { /// Maps gradient definition id to its source paint and aspect override. pub type GradientDefMap = HashSet; +pub type ImageDefMap = HashMap>; impl SvgExporter { /// Get header by pages. @@ -367,6 +373,17 @@ impl SvgExporter { Self::gradients(gradients, &mut svg); Self::patterns(patterns.into_iter(), &mut svg); svg.push("".into()); + if !t.images.is_empty() { + svg.push(r#""#.into()); + let mut images = t.images.into_iter().collect::>(); + images.sort_by_key(|(id, _)| *id); + svg.extend( + images + .into_iter() + .map(|(id, image)| render_image_def(id, &image)), + ); + svg.push("".into()); + } Self::style_defs(t.style_defs, &mut svg); } @@ -476,6 +493,8 @@ pub struct SvgTask<'a, Feat: ExportFeature> { pub gradients: GradientDefMap, /// Stores the patterns used in the document. pub patterns: PaintFillMap, + /// Stores raster images used in the document, keyed independently of placement. + pub images: ImageDefMap, _feat_phantom: std::marker::PhantomData<&'a Feat>, } @@ -489,6 +508,7 @@ impl Default for SvgTask<'_, Feat> { style_defs: StyleDefMap::default(), gradients: GradientDefMap::default(), patterns: PaintFillMap::default(), + images: ImageDefMap::default(), _feat_phantom: std::marker::PhantomData, } @@ -521,6 +541,7 @@ impl SvgTask<'_, Feat> { _style_defs: &mut self.style_defs, gradients: &mut self.gradients, patterns: &mut self.patterns, + images: &mut self.images, should_attach_debug_info: Feat::SHOULD_ATTACH_DEBUG_INFO, should_render_text_element: true, @@ -728,6 +749,53 @@ impl std::fmt::Display for RatioRepr { mod tests { use super::*; + fn image(hash: u128, data: &'static [u8], attrs: Vec) -> Arc { + image_with_format(hash, data, "png", attrs) + } + + fn image_with_format( + hash: u128, + data: &'static [u8], + format: &'static str, + attrs: Vec, + ) -> Arc { + Arc::new(ir::Image { + data: Arc::from(data), + format: format.into(), + size: Axes::new(1, 1), + hash: Fingerprint::from_u128(hash), + attrs, + }) + } + + fn render_images(images: Vec<(Arc, Size, ir::Point)>) -> String { + let mut module = Module::default(); + let mut children = Vec::new(); + for (index, (image, size, pos)) in images.into_iter().enumerate() { + let id = Fingerprint::from_u128(100 + index as u128); + module + .items + .insert(id, VecItem::Image(ir::ImageItem { image, size })); + children.push((pos, id)); + } + let page_id = Fingerprint::from_u128(1); + module + .items + .insert(page_id, VecItem::Group(ir::GroupRef(children.into()))); + let pages = [Page { + content: page_id, + size: Size::new(Scalar(100.0), Scalar(100.0)), + }]; + + SvgText::join(SvgExporter::::render( + &module, &pages, None, + )) + } + + fn occurrences(text: &str, needle: &str) -> usize { + text.match_indices(needle).count() + } + fn assert_close(actual: f64, expected: f64) { assert!( (actual - expected).abs() < 1e-6, @@ -789,4 +857,190 @@ mod tests { ); assert!(theta2 < theta1); } + + #[test] + fn repeated_image_emits_one_payload_and_multiple_uses() { + let shared = image(10, b"shared png bytes", Vec::new()); + let svg = render_images(vec![ + ( + shared.clone(), + Size::new(Scalar(10.0), Scalar(20.0)), + ir::Point::default(), + ), + ( + shared, + Size::new(Scalar(30.0), Scalar(40.0)), + ir::Point::new(Scalar(5.0), Scalar(7.0)), + ), + ]); + + assert_eq!(occurrences(&svg, "data:image/png;base64,"), 1); + assert_eq!(occurrences(&svg, r#""#).unwrap() < svg.find(""#)); + assert!(!svg.contains("data:image/")); + } + + #[test] + fn vector_images_keep_the_inline_rendering_path() { + let mut vector = image(41, b"", Vec::new()); + Arc::make_mut(&mut vector).format = "svg+xml".into(); + let svg = render_images(vec![ + ( + vector.clone(), + Size::new(Scalar(10.0), Scalar(10.0)), + ir::Point::default(), + ), + ( + vector, + Size::new(Scalar(20.0), Scalar(20.0)), + ir::Point::default(), + ), + ]); + + assert_eq!(occurrences(&svg, "data:image/svg+xml;base64,"), 2); + assert_eq!(occurrences(&svg, r#""#)); + assert!(!svg.contains(r#" { + const imageDefs = imageId + ? `` + : ''; + const imageUse = imageId ? `` : ''; + return new DOMParser().parseFromString( + `${imageDefs}${imageUse}`, + 'image/svg+xml', + ).documentElement as unknown as SVGElement; +}; + +describe('patchRoot', () => { + it('installs changed image definitions once', () => { + const current = svg(); + + patchRoot(current, svg('image-a')); + patchRoot(current, svg('image-b')); + patchRoot(current, svg('image-b')); + + const imageDefs = current.querySelector('defs.image')!; + expect(Array.from(imageDefs.children, child => child.id)).toEqual(['image-a', 'image-b']); + expect(current.querySelector('use.typst-image')?.getAttribute('href')).toBe('#image-b'); + expect(current.querySelector('#image-b')).not.toBeNull(); + }); +}); diff --git a/packages/typst.ts/src/render/svg/patch.mts b/packages/typst.ts/src/render/svg/patch.mts index 458ab924a..6ef93fe92 100644 --- a/packages/typst.ts/src/render/svg/patch.mts +++ b/packages/typst.ts/src/render/svg/patch.mts @@ -328,41 +328,50 @@ export function patchRoot(prev: SVGElement, next: SVGElement) { return; function patchSvgHeader(prev: SVGElement, next: SVGElement) { - for (let i = 0; i < 3; i++) { - const prevChild = prev.children[i]; - const nextChild = next.children[i]; - // console.log("prev", prevChild); - // console.log("next", nextChild); - if (prevChild.tagName === 'defs') { - if (prevChild.getAttribute('class') === 'glyph') { - // console.log("append glyphs:", nextChild.children, "to", prevChild); - prevChild.append(...nextChild.children); - } else if (prevChild.getAttribute('class') === 'clip-path') { - // console.log("clip path: replace"); - // todo: gc - prevChild.append(...nextChild.children); - } - } else if (prevChild.tagName === 'style' && nextChild.getAttribute('data-reuse') !== '1') { - // console.log("replace extra style", prevChild, nextChild); - - // todo: gc - if (nextChild.textContent) { - // todo: looks slow - // https://stackoverflow.com/questions/3326494/parsing-css-in-javascript-jquery - var doc = document.implementation.createHTMLDocument(''), - styleElement = document.createElement('style'); - - styleElement.textContent = nextChild.textContent; - // the style will only be parsed once it is added to a document - doc.body.appendChild(styleElement); - - const currentSvgSheet = (prevChild as HTMLStyleElement).sheet!; - const rulesToInsert = styleElement.sheet?.cssRules || []; - - // console.log("rules to insert", currentSvgSheet, rulesToInsert); - for (const rule of rulesToInsert) { - currentSvgSheet.insertRule(rule.cssText); - } + const findChild = (root: SVGElement, tagName: string, className?: string) => + Array.from(root.children).find( + child => child.tagName === tagName && (!className || child.getAttribute('class') === className), + ); + + for (const className of ['glyph', 'clip-path', 'image']) { + const nextDefs = findChild(next, 'defs', className); + if (!nextDefs) { + continue; + } + + const prevDefs = findChild(prev, 'defs', className); + if (!prevDefs) { + const before = findChild(prev, 'style') ?? findChild(prev, 'g'); + prev.insertBefore(nextDefs.cloneNode(true), before ?? null); + continue; + } + + const installedIds = new Set(Array.from(prevDefs.children, child => child.id)); + prevDefs.append(...Array.from(nextDefs.children).filter(child => !installedIds.has(child.id))); + } + + const prevStyle = findChild(prev, 'style'); + const nextStyle = findChild(next, 'style'); + if (prevStyle && nextStyle && nextStyle.getAttribute('data-reuse') !== '1') { + // console.log("replace extra style", prevStyle, nextStyle); + + // todo: gc + if (nextStyle.textContent) { + // todo: looks slow + // https://stackoverflow.com/questions/3326494/parsing-css-in-javascript-jquery + var doc = document.implementation.createHTMLDocument(''), + styleElement = document.createElement('style'); + + styleElement.textContent = nextStyle.textContent; + // the style will only be parsed once it is added to a document + doc.body.appendChild(styleElement); + + const currentSvgSheet = (prevStyle as HTMLStyleElement).sheet!; + const rulesToInsert = styleElement.sheet?.cssRules || []; + + // console.log("rules to insert", currentSvgSheet, rulesToInsert); + for (const rule of rulesToInsert) { + currentSvgSheet.insertRule(rule.cssText); } } }