From 61e4ad6bbafaa97b965a206ad06af65a0805be7e Mon Sep 17 00:00:00 2001 From: Wenzhuo Liu Date: Tue, 8 Aug 2023 18:54:13 +0800 Subject: [PATCH] Add SVG export (#1729) --- .gitignore | 1 + Cargo.lock | 7 +- crates/typst-cli/src/compile.rs | 31 +- crates/typst/Cargo.toml | 3 + crates/typst/src/export/mod.rs | 2 + crates/typst/src/export/svg.rs | 625 ++++++++++++++++++++++++++++++++ tests/src/tests.rs | 18 +- 7 files changed, 679 insertions(+), 8 deletions(-) create mode 100644 crates/typst/src/export/svg.rs diff --git a/.gitignore b/.gitignore index 608ba4603..9a368e5e0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ desktop.ini # Tests and benchmarks tests/png tests/pdf +tests/svg tests/target tarpaulin-report.html diff --git a/Cargo.lock b/Cargo.lock index 29fb6b96b..4a7f1fa43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,9 +113,9 @@ checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" [[package]] name = "base64" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" +checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" [[package]] name = "biblatex" @@ -2552,6 +2552,7 @@ checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" name = "typst" version = "0.7.0" dependencies = [ + "base64", "bitflags 2.3.1", "bytemuck", "comemo", @@ -2589,6 +2590,8 @@ dependencies = [ "unicode-segmentation", "unscanny", "usvg", + "xmlparser", + "xmlwriter", "xmp-writer", ] diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index ca088a762..331a2b1f1 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -95,10 +95,20 @@ pub fn compile_once( Ok(()) } +enum ExportImageFormat { + Png, + Svg, +} + /// Export into the target format. fn export(document: &Document, command: &CompileCommand) -> StrResult<()> { match command.output().extension() { - Some(ext) if ext.eq_ignore_ascii_case("png") => export_png(document, command), + Some(ext) if ext.eq_ignore_ascii_case("png") => { + export_image(document, command, ExportImageFormat::Png) + } + Some(ext) if ext.eq_ignore_ascii_case("svg") => { + export_image(document, command, ExportImageFormat::Svg) + } _ => export_pdf(document, command), } } @@ -112,7 +122,11 @@ fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { } /// Export to one or multiple PNGs. -fn export_png(document: &Document, command: &CompileCommand) -> StrResult<()> { +fn export_image( + document: &Document, + command: &CompileCommand, + fmt: ExportImageFormat, +) -> StrResult<()> { // Determine whether we have a `{n}` numbering. let output = command.output(); let string = output.to_str().unwrap_or_default(); @@ -128,14 +142,23 @@ fn export_png(document: &Document, command: &CompileCommand) -> StrResult<()> { let mut storage; for (i, frame) in document.pages.iter().enumerate() { - let pixmap = typst::export::render(frame, command.ppi / 72.0, Color::WHITE); let path = if numbered { storage = string.replace("{n}", &format!("{:0width$}", i + 1)); Path::new(&storage) } else { output.as_path() }; - pixmap.save_png(path).map_err(|_| "failed to write PNG file")?; + match fmt { + ExportImageFormat::Png => { + let pixmap = + typst::export::render(frame, command.ppi / 72.0, Color::WHITE); + pixmap.save_png(path).map_err(|_| "failed to write PNG file")?; + } + ExportImageFormat::Svg => { + let svg = typst::export::svg_frame(frame); + fs::write(path, svg).map_err(|_| "failed to write SVG file")?; + } + } } Ok(()) diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index 0c352a832..f1e7b6add 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -18,6 +18,7 @@ bench = false [dependencies] typst-macros = { path = "../typst-macros" } typst-syntax = { path = "../typst-syntax" } +base64 = "0.21.2" bitflags = { version = "2", features = ["serde"] } bytemuck = "1" comemo = "0.3" @@ -51,8 +52,10 @@ unicode-math-class = "0.1" unicode-segmentation = "1" unscanny = "0.1" usvg = { version = "0.32", default-features = false, features = ["text"] } +xmlwriter = "0.1.0" xmp-writer = "0.1" time = { version = "0.3.20", features = ["std", "formatting"] } +xmlparser = "0.13.5" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] stacker = "0.1.15" diff --git a/crates/typst/src/export/mod.rs b/crates/typst/src/export/mod.rs index eb0731a91..4f653c2de 100644 --- a/crates/typst/src/export/mod.rs +++ b/crates/typst/src/export/mod.rs @@ -2,6 +2,8 @@ mod pdf; mod render; +mod svg; pub use self::pdf::pdf; pub use self::render::render; +pub use self::svg::{svg, svg_frame}; diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs new file mode 100644 index 000000000..f6e576fd6 --- /dev/null +++ b/crates/typst/src/export/svg.rs @@ -0,0 +1,625 @@ +use std::{ + collections::HashMap, + fmt::{Display, Write}, + hash::Hash, + io::Read, +}; + +use base64::Engine; +use ecow::{eco_format, EcoString}; +use ttf_parser::{GlyphId, OutlineBuilder}; +use xmlwriter::XmlWriter; + +use crate::{ + doc::{Document, Frame, FrameItem, Glyph, GroupItem, TextItem}, + font::Font, + geom::{Abs, Axes, Geometry, LineCap, LineJoin, PathItem, Ratio, Shape, Transform}, + image::{ImageFormat, RasterFormat, VectorFormat}, + util::hash128, +}; +use crate::{geom::Paint::Solid, image::Image}; + +/// [`RenderHash`] is a hash value for a rendered glyph or clip path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct RenderHash(u128); + +/// Convert a [`u128`] into a [`RenderHash`]. +impl From for RenderHash { + fn from(value: u128) -> Self { + Self(value) + } +} + +/// Export a document into a SVG file. +#[tracing::instrument(skip_all)] +pub fn svg(doc: &Document) -> String { + let mut renderer = SVGRenderer::new(); + let max_page_width = doc + .pages + .iter() + .map(|page| page.size().x) + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(Abs::zero()); + let total_page_height = doc.pages.iter().map(|page| page.size().y).sum::(); + let doc_size = Axes { x: max_page_width, y: total_page_height }; + renderer.header(doc_size); + let mut y_offset = Abs::zero(); + for page in &doc.pages { + renderer.render_frame(page, Transform::translate(Abs::zero(), y_offset)); + y_offset += page.size().y; + } + renderer.finalize() +} + +/// Export a frame into a SVG file. +#[tracing::instrument(skip_all)] +pub fn svg_frame(frame: &Frame) -> String { + let mut renderer = SVGRenderer::new(); + renderer.header(frame.size()); + renderer.render_frame(frame, Transform::identity()); + renderer.finalize() +} + +/// [`RenderedGlyph`] represet glyph to be rendered. +enum RenderedGlyph { + /// A path is a sequence of drawing commands. + /// It is in the format of `M x y L x y C x1 y1 x2 y2 x y Z`. + Path(EcoString), + /// An image is a URL to an image file, plus the size and transform. The url is in the + /// format of `data:image/{format};base64,`. + Image { url: EcoString, width: f64, height: f64, ts: Transform }, +} + +/// [`DedupVec`] is a vector that deduplicates its elements. It is used to deduplicate glyphs and +/// clip paths. +/// The `H` is the hash type, and `T` is the value type. The `PREFIX` is the prefix of the index. +/// This is used to distinguish between glyphs and clip paths. +#[derive(Debug, Clone)] +struct DedupVec { + vec: Vec, + present: HashMap, +} + +impl DedupVec +where + H: Eq + Hash + Copy, +{ + fn new() -> Self { + Self { vec: Vec::new(), present: HashMap::new() } + } + + /// Insert a value into the vector. If the value is already present, return the index of the + /// existing value. And the value_fn will not be called. Otherwise, insert the value and + /// return the index of the inserted value. The index is the position of the value in the + /// vector. + #[must_use = "This method returns the index of the inserted value"] + fn insert_with(&mut self, hash: H, value_fn: impl FnOnce() -> T) -> usize { + if let Some(index) = self.present.get(&hash) { + *index + } else { + let index = self.vec.len(); + self.vec.push(value_fn()); + self.present.insert(hash, index); + index + } + } + + fn iter(&self) -> impl Iterator { + self.vec.iter() + } + + fn prefix(&self) -> char { + PREFIX + } +} + +impl IntoIterator for DedupVec { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.vec.into_iter() + } +} + +/// [`SVGRenderer`] is a renderer that renders a document or frame into a SVG file. +struct SVGRenderer { + xml: XmlWriter, + glyphs: DedupVec, + /// Clip paths are used to clip a group. A clip path is a path that defines the clipping + /// region. The clip path is referenced by the `clip-path` attribute of the group. + /// The clip path is in the format of `M x y L x y C x1 y1 x2 y2 x y Z`. + clip_paths: DedupVec, +} + +impl SVGRenderer { + /// Create a new SVG renderer with empty glyph and clip path. + fn new() -> Self { + SVGRenderer { + xml: XmlWriter::new(xmlwriter::Options::default()), + glyphs: DedupVec::new(), + clip_paths: DedupVec::new(), + } + } + + /// Write the SVG header, including the `viewBox` and `width` and `height` attributes. + fn header(&mut self, size: Axes) { + self.xml.start_element("svg"); + self.xml.write_attribute("class", "typst-doc"); + self.xml.write_attribute_fmt( + "viewBox", + format_args!("0 0 {} {}", size.x.to_pt(), size.y.to_pt()), + ); + self.xml.write_attribute("width", &size.x.to_pt()); + self.xml.write_attribute("height", &size.y.to_pt()); + self.xml.write_attribute("xmlns", "http://www.w3.org/2000/svg"); + self.xml + .write_attribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + self.xml.write_attribute("xmlns:h5", "http://www.w3.org/1999/xhtml"); + } + + /// Build the glyph definitions. + fn build_glyph(&mut self) { + self.xml.start_element("defs"); + self.xml.write_attribute("id", "glyph"); + for (id, glyph) in self.glyphs.iter().enumerate() { + self.xml.start_element("symbol"); + self.xml.write_attribute_fmt( + "id", + format_args!("{}{}", self.glyphs.prefix(), id), + ); + self.xml.write_attribute("overflow", "visible"); + match glyph { + RenderedGlyph::Path(path) => { + self.xml.start_element("path"); + self.xml.write_attribute("d", &path); + self.xml.end_element(); + } + RenderedGlyph::Image { url, width, height, ts } => { + self.xml.start_element("image"); + self.xml.write_attribute("xlink:href", &url); + self.xml.write_attribute("width", &width); + self.xml.write_attribute("height", &height); + if !ts.is_identity() { + self.xml.write_attribute("transform", &ts); + } + self.xml.write_attribute("preserveAspectRatio", "none"); + self.xml.end_element(); + } + } + self.xml.end_element(); + } + self.xml.end_element(); + } + + /// Build the clip path definitions. + fn build_clip_path(&mut self) { + self.xml.start_element("defs"); + self.xml.write_attribute("id", "clip-path"); + for (id, path) in self.clip_paths.iter().enumerate() { + self.xml.start_element("clipPath"); + self.xml.write_attribute_fmt( + "id", + format_args!("{}{}", self.clip_paths.prefix(), id), + ); + self.xml.start_element("path"); + self.xml.write_attribute("d", &path); + self.xml.end_element(); + self.xml.end_element(); + } + self.xml.end_element(); + } + + /// Finalize the SVG file. This must be called after all rendering is done. + fn finalize(mut self) -> String { + self.build_clip_path(); + self.build_glyph(); + self.xml.end_document() + } + + /// Render a frame with the given transform. + fn render_frame(&mut self, frame: &Frame, ts: Transform) { + self.xml.start_element("g"); + if !ts.is_identity() { + self.xml.write_attribute("transform", &ts); + }; + for (pos, item) in frame.items() { + let x = pos.x.to_pt(); + let y = pos.y.to_pt(); + self.xml.start_element("g"); + self.xml + .write_attribute_fmt("transform", format_args!("translate({} {})", x, y)); + match item { + FrameItem::Group(group) => self.render_group(group), + FrameItem::Text(text) => self.render_text(text), + FrameItem::Shape(shape, _) => self.render_shape(shape), + FrameItem::Image(image, size, _) => self.render_image(image, size), + FrameItem::Meta(_, _) => {} + }; + self.xml.end_element(); + } + self.xml.end_element(); + } + + /// Render a group. If the group has `clips` set to true, a clip path will be created. + fn render_group(&mut self, group: &GroupItem) { + self.xml.start_element("g"); + self.xml.write_attribute("class", "typst-group"); + if group.clips { + let clip_path_hash = hash128(&group).into(); + let x = group.frame.size().x.to_pt(); + let y = group.frame.size().y.to_pt(); + let id = self.clip_paths.insert_with(clip_path_hash, || { + let mut builder = SVGPath2DBuilder(EcoString::new()); + builder.rect(x as f32, y as f32); + builder.0 + }); + self.xml.write_attribute_fmt( + "clip-path", + format_args!("url(#{}{})", self.clip_paths.prefix(), id), + ); + } + self.render_frame(&group.frame, group.transform); + self.xml.end_element(); + } + + /// Render a text item. The text is rendered as a group of glyphs. + /// We will try to render the text as SVG first, then bitmap, then outline. + /// If none of them works, we will skip the text. + fn render_text(&mut self, text: &TextItem) { + let scale: f64 = text.size.to_pt() / text.font.units_per_em(); + let inv_scale: f64 = text.font.units_per_em() / text.size.to_pt(); + self.xml.start_element("g"); + self.xml.write_attribute("class", "typst-text"); + self.xml.write_attribute_fmt( + "transform", + format_args!("scale({} {})", scale, -scale), + ); + let mut x_offset: f64 = 0.0; + for glyph in &text.glyphs { + let offset = x_offset + glyph.x_offset.at(text.size).to_pt(); + self.render_svg_glyph(text, glyph, offset, inv_scale) + .or_else(|| self.render_bitmap_glyph(text, glyph, offset, inv_scale)) + .or_else(|| self.render_outline_glyph(text, glyph, offset, inv_scale)); + x_offset += glyph.x_advance.at(text.size).to_pt(); + } + self.xml.end_element(); + } + + fn render_svg_glyph( + &mut self, + text: &TextItem, + glyph: &Glyph, + x_offset: f64, + inv_scale: f64, + ) -> Option<()> { + #[comemo::memoize] + fn build_svg_glyph(font: &Font, glyph_id: u16) -> Option { + let mut data = font.ttf().glyph_svg_image(GlyphId(glyph_id))?; + // Decompress SVGZ. + let mut decoded = vec![]; + // The first three bytes of the gzip-encoded document header must be 0x1F, 0x8B, + // 0x08. + if data.starts_with(&[0x1f, 0x8b]) { + let mut decoder = flate2::read::GzDecoder::new(data); + decoder.read_to_end(&mut decoded).ok()?; + data = &decoded; + } + + let upem = Abs::raw(font.units_per_em()); + let (width, height) = (upem.to_pt(), upem.to_pt()); + let origin_ascender = font.metrics().ascender.at(upem).to_pt(); + + // Parse XML. + let mut svg_str = std::str::from_utf8(data).ok()?.to_owned(); + let document = xmlparser::Tokenizer::from(svg_str.as_str()); + let mut start_span = None; + let mut last_viewbox = None; + // Parse xml and find the viewBox of the svg element. + // ... + // ~~~~~^~~~~~~ + for n in document { + let tok = n.unwrap(); + match tok { + xmlparser::Token::ElementStart { span, local, .. } => { + if local.as_str() == "svg" { + start_span = Some(span); + break; + } + } + xmlparser::Token::Attribute { span, local, value, .. } => { + if local.as_str() == "viewBox" { + last_viewbox = Some((span, value)); + } + } + xmlparser::Token::ElementEnd { .. } => break, + _ => {} + } + } + + if last_viewbox.is_none() { + // correct the viewbox if it is not present + // `-origin_ascender` is to make sure the glyph is rendered at the correct position + svg_str.insert_str( + start_span.unwrap().range().end, + format!(r#" viewBox="0 {} {} {}""#, -origin_ascender, width, height) + .as_str(), + ); + } + let mut url: EcoString = "data:image/svg+xml;base64,".into(); + let b64_encoded = + base64::engine::general_purpose::STANDARD.encode(svg_str.as_bytes()); + url.push_str(&b64_encoded); + Some(url) + } + + let data_url = build_svg_glyph(&text.font, glyph.id)?; + let upem = Abs::raw(text.font.units_per_em()); + let origin_ascender = text.font.metrics().ascender.at(upem).to_pt(); + let glyph_hash: RenderHash = hash128(&(&text.font, glyph.id)).into(); + let id = self.glyphs.insert_with(glyph_hash, || RenderedGlyph::Image { + url: data_url, + width: upem.to_pt(), + height: upem.to_pt(), + ts: Transform::translate(Abs::zero(), Abs::pt(-origin_ascender)) + .post_concat(Transform::scale(Ratio::new(1.0), Ratio::new(-1.0))), + }); + + self.xml.start_element("use"); + self.xml.write_attribute_fmt( + "xlink:href", + format_args!("#{}{}", self.glyphs.prefix(), id), + ); + self.xml + .write_attribute_fmt("x", format_args!("{}", x_offset * inv_scale)); + self.xml.end_element(); + Some(()) + } + + fn render_bitmap_glyph( + &mut self, + text: &TextItem, + glyph: &Glyph, + x_offset: f64, + inv_scale: f64, + ) -> Option<()> { + #[comemo::memoize] + fn build_bitmap_glyph(font: &Font, glyph_id: u16) -> Option<(Image, i16, i16)> { + let bitmap = + font.ttf().glyph_raster_image(GlyphId(glyph_id), std::u16::MAX)?; + let image = + Image::new(bitmap.data.into(), bitmap.format.into(), None).ok()?; + Some((image, bitmap.x, bitmap.y)) + } + let glyph_hash: RenderHash = hash128(&(&text.font, glyph.id)).into(); + let (image, bitmap_x_offset, bitmap_y_offset) = + build_bitmap_glyph(&text.font, glyph.id)?; + let (bitmap_x_offset, bitmap_y_offset) = + (bitmap_x_offset as f64, bitmap_y_offset as f64); + let id = self.glyphs.insert_with(glyph_hash, || { + let width = image.width() as f64; + let height = image.height() as f64; + let url = encode_image_to_url(&image); + let ts = Transform::translate( + Abs::pt(bitmap_x_offset), + Abs::pt(-height - bitmap_y_offset), + ); + RenderedGlyph::Image { url, width, height, ts } + }); + let target_height = text.size.to_pt(); + self.xml.start_element("use"); + self.xml.write_attribute_fmt( + "xlink:href", + format_args!("#{}{}", self.glyphs.prefix(), id), + ); + // The image is stored with the height of `image.height()`, but we want to render it with a + // height of `target_height`. So we need to scale it. + let scale_factor = target_height / image.height() as f64; + self.xml.write_attribute("x", &(x_offset / scale_factor)); + self.xml.write_attribute_fmt( + "transform", + format_args!( + "scale({} -{})", + inv_scale * scale_factor, + inv_scale * scale_factor, + ), + ); + self.xml.end_element(); + Some(()) + } + + fn render_outline_glyph( + &mut self, + text: &TextItem, + glyph: &Glyph, + x_offset: f64, + inv_scale: f64, + ) -> Option<()> { + #[comemo::memoize] + fn build_outline_glyph(font: &Font, glyph_id: u16) -> Option { + let mut builder = SVGPath2DBuilder(EcoString::new()); + font.ttf().outline_glyph(GlyphId(glyph_id), &mut builder)?; + Some(builder.0) + } + let path = build_outline_glyph(&text.font, glyph.id)?; + let glyph_hash = hash128(&(&text.font, glyph.id)).into(); + let id = self.glyphs.insert_with(glyph_hash, || RenderedGlyph::Path(path)); + let Solid(text_color) = text.fill; + self.xml.start_element("use"); + self.xml.write_attribute_fmt( + "xlink:href", + format_args!("#{}{}", self.glyphs.prefix(), id), + ); + self.xml + .write_attribute_fmt("x", format_args!("{}", x_offset * inv_scale)); + self.xml.write_attribute("fill", &text_color.to_rgba().to_hex()); + self.xml.end_element(); + Some(()) + } + + fn render_shape(&mut self, shape: &Shape) { + self.xml.start_element("path"); + self.xml.write_attribute("class", "typst-shape"); + if let Some(paint) = &shape.fill { + let Solid(color) = paint; + self.xml.write_attribute("fill", &color.to_rgba().to_hex()); + } else { + self.xml.write_attribute("fill", "none"); + } + if let Some(stroke) = &shape.stroke { + let Solid(color) = stroke.paint; + self.xml.write_attribute("stroke", &color.to_rgba().to_hex()); + self.xml.write_attribute("stroke-width", &stroke.thickness.to_pt()); + self.xml.write_attribute( + "stroke-linecap", + match stroke.line_cap { + LineCap::Butt => "butt", + LineCap::Round => "round", + LineCap::Square => "square", + }, + ); + self.xml.write_attribute( + "stoke-linejoin", + match stroke.line_join { + LineJoin::Miter => "miter", + LineJoin::Round => "round", + LineJoin::Bevel => "bevel", + }, + ); + self.xml.write_attribute("stoke-miterlimit", &stroke.miter_limit.0); + if let Some(pattern) = &stroke.dash_pattern { + self.xml.write_attribute("stoken-dashoffset", &pattern.phase.to_pt()); + self.xml.write_attribute( + "stoken-dasharray", + &pattern + .array + .iter() + .map(|dash| dash.to_pt().to_string()) + .collect::>() + .join(" "), + ); + } + } + #[comemo::memoize] + fn build_shape(geometry: &Geometry) -> EcoString { + let mut path_builder = SVGPath2DBuilder(EcoString::new()); + match geometry { + Geometry::Line(t) => { + path_builder.move_to(0.0, 0.0); + path_builder.line_to(t.x.to_pt() as f32, t.y.to_pt() as f32); + } + Geometry::Rect(rect) => { + let x = rect.x.to_pt() as f32; + let y = rect.y.to_pt() as f32; + path_builder.rect(x, y); + } + Geometry::Path(p) => { + for item in &p.0 { + match item { + PathItem::MoveTo(m) => path_builder + .move_to(m.x.to_pt() as f32, m.y.to_pt() as f32), + PathItem::LineTo(l) => path_builder + .line_to(l.x.to_pt() as f32, l.y.to_pt() as f32), + PathItem::CubicTo(c1, c2, t) => path_builder.curve_to( + c1.x.to_pt() as f32, + c1.y.to_pt() as f32, + c2.x.to_pt() as f32, + c2.y.to_pt() as f32, + t.x.to_pt() as f32, + t.y.to_pt() as f32, + ), + PathItem::ClosePath => path_builder.close(), + } + } + } + }; + path_builder.0 + } + let shape_path = build_shape(&shape.geometry); + self.xml.write_attribute("d", &shape_path); + self.xml.end_element(); + } + + fn render_image(&mut self, image: &Image, size: &Axes) { + let url = encode_image_to_url(image); + self.xml.start_element("image"); + self.xml.write_attribute("xlink:href", &url); + self.xml.write_attribute("width", &size.x.to_pt()); + self.xml.write_attribute("height", &size.y.to_pt()); + self.xml.write_attribute("preserveAspectRatio", "none"); + self.xml.end_element(); + } +} + +/// Encode an image into a data URL. The format of the URL is `data:image/{format};base64,`. +#[comemo::memoize] +fn encode_image_to_url(image: &Image) -> EcoString { + let format = match image.format() { + ImageFormat::Raster(f) => match f { + RasterFormat::Png => "png", + RasterFormat::Jpg => "jpeg", + RasterFormat::Gif => "gif", + }, + ImageFormat::Vector(f) => match f { + VectorFormat::Svg => "svg+xml", + }, + }; + let mut url = eco_format!("data:image/{};base64,", format); + let data = base64::engine::general_purpose::STANDARD.encode(image.data()); + url.push_str(&data); + url +} + +impl Display for Transform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Convert a [`Transform`] into a SVG transform string. + // See https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform + write!( + f, + "matrix({} {} {} {} {} {})", + self.sx.get(), + self.ky.get(), + self.kx.get(), + self.sy.get(), + self.tx.to_pt(), + self.ty.to_pt() + ) + } +} +/// A builder for SVG path. +struct SVGPath2DBuilder(pub EcoString); + +impl SVGPath2DBuilder { + /// Create a rectangle path. The rectangle is created with the top-left corner at (0, 0). + /// The width and height are the size of the rectangle. + fn rect(&mut self, width: f32, height: f32) { + self.move_to(0.0, 0.0); + self.line_to(0.0, height); + self.line_to(width, height); + self.line_to(width, 0.0); + self.close(); + } +} + +/// A builder for SVG path. This is used to build the path for a glyph. +impl ttf_parser::OutlineBuilder for SVGPath2DBuilder { + fn move_to(&mut self, x: f32, y: f32) { + write!(&mut self.0, "M {} {} ", x, y).unwrap(); + } + + fn line_to(&mut self, x: f32, y: f32) { + write!(&mut self.0, "L {} {} ", x, y).unwrap(); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + write!(&mut self.0, "Q {} {} {} {} ", x1, y1, x, y).unwrap(); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + write!(&mut self.0, "C {} {} {} {} {} {} ", x1, y1, x2, y2, x, y).unwrap(); + } + + fn close(&mut self) { + write!(&mut self.0, "Z ").unwrap(); + } +} diff --git a/tests/src/tests.rs b/tests/src/tests.rs index 518233613..b5f80663c 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -35,6 +35,7 @@ const TYP_DIR: &str = "typ"; const REF_DIR: &str = "ref"; const PNG_DIR: &str = "png"; const PDF_DIR: &str = "pdf"; +const SVG_DIR: &str = "svg"; const FONT_DIR: &str = "../assets/fonts"; const ASSET_DIR: &str = "../assets"; @@ -116,11 +117,19 @@ fn main() { let path = src_path.strip_prefix(TYP_DIR).unwrap(); let png_path = Path::new(PNG_DIR).join(path).with_extension("png"); let ref_path = Path::new(REF_DIR).join(path).with_extension("png"); + let svg_path = Path::new(SVG_DIR).join(path).with_extension("svg"); let pdf_path = args.pdf.then(|| Path::new(PDF_DIR).join(path).with_extension("pdf")); - test(world, &src_path, &png_path, &ref_path, pdf_path.as_deref(), &args) - as usize + test( + world, + &src_path, + &png_path, + &ref_path, + pdf_path.as_deref(), + &svg_path, + &args, + ) as usize }) .collect::>(); @@ -328,6 +337,7 @@ fn test( png_path: &Path, ref_path: &Path, pdf_path: Option<&Path>, + svg_path: &Path, args: &Args, ) -> bool { struct PanicGuard<'a>(&'a Path); @@ -419,6 +429,10 @@ fn test( fs::create_dir_all(png_path.parent().unwrap()).unwrap(); canvas.save_png(png_path).unwrap(); + let svg = typst::export::svg(&document); + fs::create_dir_all(svg_path.parent().unwrap()).unwrap(); + std::fs::write(svg_path, svg).unwrap(); + if let Ok(ref_pixmap) = sk::Pixmap::load_png(ref_path) { if canvas.width() != ref_pixmap.width() || canvas.height() != ref_pixmap.height()