diff options
| author | Laurenz <laurmaedje@gmail.com> | 2023-08-08 12:59:21 +0200 |
|---|---|---|
| committer | Laurenz <laurmaedje@gmail.com> | 2023-08-08 13:00:47 +0200 |
| commit | 2ea451b83b919e5ed59ad203bb22efa844660bd1 (patch) | |
| tree | 96b718c4e313c4a2ef12368cef5164df64c118cd | |
| parent | 61e4ad6bbafaa97b965a206ad06af65a0805be7e (diff) | |
Refactor SVG export a bit
| -rw-r--r-- | crates/typst-cli/src/compile.rs | 25 | ||||
| -rw-r--r-- | crates/typst/src/export/mod.rs | 4 | ||||
| -rw-r--r-- | crates/typst/src/export/render.rs | 41 | ||||
| -rw-r--r-- | crates/typst/src/export/svg.rs | 796 | ||||
| -rw-r--r-- | tests/src/tests.rs | 53 |
5 files changed, 490 insertions, 429 deletions
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 331a2b1f..0fa66d62 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -95,19 +95,14 @@ 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_image(document, command, ExportImageFormat::Png) + export_image(document, command, ImageExportFormat::Png) } Some(ext) if ext.eq_ignore_ascii_case("svg") => { - export_image(document, command, ExportImageFormat::Svg) + export_image(document, command, ImageExportFormat::Svg) } _ => export_pdf(document, command), } @@ -121,18 +116,24 @@ fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { Ok(()) } +/// An image format to export in. +enum ImageExportFormat { + Png, + Svg, +} + /// Export to one or multiple PNGs. fn export_image( document: &Document, command: &CompileCommand, - fmt: ExportImageFormat, + fmt: ImageExportFormat, ) -> StrResult<()> { // Determine whether we have a `{n}` numbering. let output = command.output(); let string = output.to_str().unwrap_or_default(); let numbered = string.contains("{n}"); if !numbered && document.pages.len() > 1 { - bail!("cannot export multiple PNGs without `{{n}}` in output path"); + bail!("cannot export multiple images without `{{n}}` in output path"); } // Find a number width that accommodates all pages. For instance, the @@ -149,13 +150,13 @@ fn export_image( output.as_path() }; match fmt { - ExportImageFormat::Png => { + ImageExportFormat::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); + ImageExportFormat::Svg => { + let svg = typst::export::svg(frame); fs::write(path, svg).map_err(|_| "failed to write SVG file")?; } } diff --git a/crates/typst/src/export/mod.rs b/crates/typst/src/export/mod.rs index 4f653c2d..65d3f184 100644 --- a/crates/typst/src/export/mod.rs +++ b/crates/typst/src/export/mod.rs @@ -5,5 +5,5 @@ mod render; mod svg; pub use self::pdf::pdf; -pub use self::render::render; -pub use self::svg::{svg, svg_frame}; +pub use self::render::{render, render_merged}; +pub use self::svg::{svg, svg_merged}; diff --git a/crates/typst/src/export/render.rs b/crates/typst/src/export/render.rs index d8115b12..145e64a3 100644 --- a/crates/typst/src/export/render.rs +++ b/crates/typst/src/export/render.rs @@ -37,6 +37,47 @@ pub fn render(frame: &Frame, pixel_per_pt: f32, fill: Color) -> sk::Pixmap { canvas } +/// Export multiple frames into a single raster image. +/// +/// The padding will be added around and between the individual frames. +pub fn render_merged( + frames: &[Frame], + pixel_per_pt: f32, + frame_fill: Color, + padding: Abs, + padding_fill: Color, +) -> sk::Pixmap { + let pixmaps: Vec<_> = frames + .iter() + .map(|frame| typst::export::render(frame, pixel_per_pt, frame_fill)) + .collect(); + + let padding = (pixel_per_pt * padding.to_f32()).round() as u32; + let pxw = + 2 * padding + pixmaps.iter().map(sk::Pixmap::width).max().unwrap_or_default(); + let pxh = + padding + pixmaps.iter().map(|pixmap| pixmap.height() + padding).sum::<u32>(); + + let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap(); + canvas.fill(padding_fill.into()); + + let [x, mut y] = [padding; 2]; + for pixmap in pixmaps { + canvas.draw_pixmap( + x as i32, + y as i32, + pixmap.as_ref(), + &sk::PixmapPaint::default(), + sk::Transform::identity(), + None, + ); + + y += pixmap.height() + padding; + } + + canvas +} + /// Render a frame into the canvas. fn render_frame( canvas: &mut sk::Pixmap, diff --git a/crates/typst/src/export/svg.rs b/crates/typst/src/export/svg.rs index f6e576fd..24c4b1a3 100644 --- a/crates/typst/src/export/svg.rs +++ b/crates/typst/src/export/svg.rs @@ -1,135 +1,75 @@ -use std::{ - collections::HashMap, - fmt::{Display, Write}, - hash::Hash, - io::Read, -}; +use std::collections::HashMap; +use std::fmt::{self, Display, Formatter, Write}; +use std::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::doc::{Frame, FrameItem, GroupItem, TextItem}; +use crate::font::Font; +use crate::geom::{ + Abs, Axes, Geometry, LineCap, LineJoin, Paint, PathItem, Ratio, Shape, Size, Stroke, + Transform, }; -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<u128> 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::<Abs>(); - 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() -} +use crate::image::{Image, ImageFormat, RasterFormat, VectorFormat}; +use crate::util::hash128; /// Export a frame into a SVG file. #[tracing::instrument(skip_all)] -pub fn svg_frame(frame: &Frame) -> String { +pub fn svg(frame: &Frame) -> String { let mut renderer = SVGRenderer::new(); - renderer.header(frame.size()); + renderer.write_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<H, T, const PREFIX: char> { - vec: Vec<T>, - present: HashMap<H, usize>, -} - -impl<H, T, const PREFIX: char> DedupVec<H, T, PREFIX> -where - H: Eq + Hash + Copy, -{ - fn new() -> Self { - Self { vec: Vec::new(), present: HashMap::new() } - } +/// Export multiple frames into a single SVG file. +/// +/// The padding will be added around and between the individual frames. +#[tracing::instrument(skip_all)] +pub fn svg_merged(frames: &[Frame], padding: Abs) -> String { + let width = 2.0 * padding + + frames.iter().map(|frame| frame.width()).max().unwrap_or_default(); + let height = padding + frames.iter().map(|page| page.height() + padding).sum::<Abs>(); + let size = Size::new(width, height); - /// 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 - } - } + let mut renderer = SVGRenderer::new(); + renderer.write_header(size); - fn iter(&self) -> impl Iterator<Item = &T> { - self.vec.iter() + let [x, mut y] = [padding; 2]; + for frame in frames { + renderer.render_frame(frame, Transform::translate(x, y)); + y += frame.height() + padding; } - fn prefix(&self) -> char { - PREFIX - } -} - -impl<H, T, const PREFIX: char> IntoIterator for DedupVec<H, T, PREFIX> { - type Item = T; - type IntoIter = std::vec::IntoIter<Self::Item>; - - fn into_iter(self) -> Self::IntoIter { - self.vec.into_iter() - } + renderer.finalize() } -/// [`SVGRenderer`] is a renderer that renders a document or frame into a SVG file. +/// Renders one or multiple frames to an SVG file. struct SVGRenderer { + /// The internal XML writer. xml: XmlWriter, - glyphs: DedupVec<RenderHash, RenderedGlyph, 'g'>, - /// 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<RenderHash, EcoString, 'c'>, + /// Prepared glyphs. + glyphs: Deduplicator<RenderedGlyph>, + /// 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: Deduplicator<EcoString>, +} + +/// Represents a 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 }, } impl SVGRenderer { @@ -137,13 +77,14 @@ impl SVGRenderer { fn new() -> Self { SVGRenderer { xml: XmlWriter::new(xmlwriter::Options::default()), - glyphs: DedupVec::new(), - clip_paths: DedupVec::new(), + glyphs: Deduplicator::new('g'), + clip_paths: Deduplicator::new('c'), } } - /// Write the SVG header, including the `viewBox` and `width` and `height` attributes. - fn header(&mut self, size: Axes<Abs>) { + /// Write the SVG header, including the `viewBox` and `width` and `height` + /// attributes. + fn write_header(&mut self, size: Size) { self.xml.start_element("svg"); self.xml.write_attribute("class", "typst-doc"); self.xml.write_attribute_fmt( @@ -158,77 +99,20 @@ impl SVGRenderer { 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); - }; + self.xml.write_attribute("transform", &SvgMatrix(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)); + .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), @@ -236,127 +120,78 @@ impl SVGRenderer { 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. + /// 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()); + let hash = hash128(&group); + let size = group.frame.size(); + let x = size.x.to_pt(); + let y = size.y.to_pt(); + let id = self.clip_paths.insert_with(hash, || { + let mut builder = SvgPathBuilder(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.xml.write_attribute_fmt("clip-path", format_args!("url(#{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. + /// 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; + + let mut x: 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(); + let id = GlyphId(glyph.id); + let offset = x + glyph.x_offset.at(text.size).to_pt(); + + self.render_svg_glyph(text, id, offset, inv_scale) + .or_else(|| self.render_bitmap_glyph(text, id, offset, inv_scale)) + .or_else(|| self.render_outline_glyph(text, id, offset, inv_scale)); + + x += glyph.x_advance.at(text.size).to_pt(); } + self.xml.end_element(); } + /// Render a glyph defined by an SVG. fn render_svg_glyph( &mut self, text: &TextItem, - glyph: &Glyph, + id: GlyphId, x_offset: f64, inv_scale: f64, ) -> Option<()> { - #[comemo::memoize] - fn build_svg_glyph(font: &Font, glyph_id: u16) -> Option<EcoString> { - 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. - // <svg viewBox="0 0 1000 1000">...</svg> - // ~~~~~^~~~~~~ - 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 data_url = convert_svg_glyph_to_base64_url(&text.font, 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 glyph_hash = hash128(&(&text.font, id)); let id = self.glyphs.insert_with(glyph_hash, || RenderedGlyph::Image { url: data_url, width: upem.to_pt(), @@ -366,54 +201,44 @@ impl SVGRenderer { }); self.xml.start_element("use"); - self.xml.write_attribute_fmt( - "xlink:href", - format_args!("#{}{}", self.glyphs.prefix(), id), - ); + self.xml.write_attribute_fmt("xlink:href", format_args!("#{id}")); self.xml .write_attribute_fmt("x", format_args!("{}", x_offset * inv_scale)); self.xml.end_element(); + Some(()) } + /// Render a glyph defined by a bitmap. fn render_bitmap_glyph( &mut self, text: &TextItem, - glyph: &Glyph, + id: GlyphId, 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); + convert_bitmap_glyph_to_image(&text.font, id)?; + + let glyph_hash = hash128(&(&text.font, id)); 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 url = convert_image_to_base64_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. + self.xml.write_attribute_fmt("xlink:href", format_args!("#{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( @@ -425,123 +250,97 @@ impl SVGRenderer { ), ); self.xml.end_element(); + Some(()) } + /// Render a glyph defined by an outline. fn render_outline_glyph( &mut self, text: &TextItem, - glyph: &Glyph, + id: GlyphId, x_offset: f64, inv_scale: f64, ) -> Option<()> { - #[comemo::memoize] - fn build_outline_glyph(font: &Font, glyph_id: u16) -> Option<EcoString> { - 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; + let path = convert_outline_glyph_to_path(&text.font, id)?; + let hash = hash128(&(&text.font, id)); + let id = self.glyphs.insert_with(hash, || RenderedGlyph::Path(path)); + self.xml.start_element("use"); - self.xml.write_attribute_fmt( - "xlink:href", - format_args!("#{}{}", self.glyphs.prefix(), id), - ); + self.xml.write_attribute_fmt("xlink:href", format_args!("#{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.write_fill(&text.fill); self.xml.end_element(); + Some(()) } + /// Render a shape element. 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()); + self.write_fill(paint); } 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.write_stroke(stroke); + } + + let path = convert_geometry_to_path(&shape.geometry); + self.xml.write_attribute("d", &path); + self.xml.end_element(); + } + + /// Write a fill attribute. + fn write_fill(&mut self, fill: &Paint) { + let Paint::Solid(color) = fill; + self.xml.write_attribute("fill", &color.to_rgba().to_hex()); + } + + /// Write a stroke attribute. + fn write_stroke(&mut self, stroke: &Stroke) { + let Paint::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( - "stoke-linejoin", - match stroke.line_join { - LineJoin::Miter => "miter", - LineJoin::Round => "round", - LineJoin::Bevel => "bevel", - }, + "stoken-dasharray", + &pattern + .array + .iter() + .map(|dash| dash.to_pt().to_string()) + .collect::<Vec<_>>() + .join(" "), ); - 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::<Vec<_>>() - .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(); } + /// Render an image element. fn render_image(&mut self, image: &Image, size: &Axes<Abs>) { - let url = encode_image_to_url(image); + let url = convert_image_to_base64_url(image); self.xml.start_element("image"); self.xml.write_attribute("xlink:href", &url); self.xml.write_attribute("width", &size.x.to_pt()); @@ -549,11 +348,187 @@ impl SVGRenderer { self.xml.write_attribute("preserveAspectRatio", "none"); self.xml.end_element(); } + + /// Finalize the SVG file. This must be called after all rendering is done. + fn finalize(mut self) -> String { + self.write_glyph_defs(); + self.write_clip_path_defs(); + self.xml.end_document() + } + + /// Build the glyph definitions. + fn write_glyph_defs(&mut self) { + self.xml.start_element("defs"); + self.xml.write_attribute("id", "glyph"); + + for (id, glyph) in self.glyphs.iter() { + self.xml.start_element("symbol"); + self.xml.write_attribute("id", &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", &SvgMatrix(*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 write_clip_path_defs(&mut self) { + self.xml.start_element("defs"); + self.xml.write_attribute("id", "clip-path"); + + for (id, path) in self.clip_paths.iter() { + self.xml.start_element("clipPath"); + self.xml.write_attribute("id", &id); + self.xml.start_element("path"); + self.xml.write_attribute("d", &path); + self.xml.end_element(); + self.xml.end_element(); + } + + self.xml.end_element(); + } +} + +/// Convert an outline glyph to an SVG path. +#[comemo::memoize] +fn convert_outline_glyph_to_path(font: &Font, id: GlyphId) -> Option<EcoString> { + let mut builder = SvgPathBuilder::default(); + font.ttf().outline_glyph(id, &mut builder)?; + Some(builder.0) +} + +/// Convert a bitmap glyph to an encoded image URL. +#[comemo::memoize] +fn convert_bitmap_glyph_to_image(font: &Font, id: GlyphId) -> Option<(Image, f64, f64)> { + let bitmap = font.ttf().glyph_raster_image(id, std::u16::MAX)?; + let image = Image::new(bitmap.data.into(), bitmap.format.into(), None).ok()?; + Some((image, bitmap.x as f64, bitmap.y as f64)) +} + +/// Convert an SVG glyph to an encoded image URL. +#[comemo::memoize] +fn convert_svg_glyph_to_base64_url(font: &Font, id: GlyphId) -> Option<EcoString> { + let mut data = font.ttf().glyph_svg_image(id)?; + + // Decompress SVGZ. + let mut decoded = vec![]; + 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 mut start_span = None; + let mut last_viewbox = None; + + // Parse xml and find the viewBox of the svg element. + // <svg viewBox="0 0 1000 1000">...</svg> + // ~~~~~^~~~~~~ + for n in xmlparser::Tokenizer::from(svg_str.as_str()) { + 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 {} {width} {height}""#, -origin_ascender).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) +} + +/// Convert a geometry to an SVG path. +#[comemo::memoize] +fn convert_geometry_to_path(geometry: &Geometry) -> EcoString { + let mut builder = SvgPathBuilder::default(); + match geometry { + Geometry::Line(t) => { + builder.move_to(0.0, 0.0); + 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; + builder.rect(x, y); + } + Geometry::Path(p) => { + for item in &p.0 { + match item { + PathItem::MoveTo(m) => { + builder.move_to(m.x.to_pt() as f32, m.y.to_pt() as f32) + } + PathItem::LineTo(l) => { + builder.line_to(l.x.to_pt() as f32, l.y.to_pt() as f32) + } + PathItem::CubicTo(c1, c2, t) => 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 => builder.close(), + } + } + } + }; + builder.0 } -/// Encode an image into a data URL. The format of the URL is `data:image/{format};base64,`. +/// 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 { +fn convert_image_to_base64_url(image: &Image) -> EcoString { let format = match image.format() { ImageFormat::Raster(f) => match f { RasterFormat::Png => "png", @@ -564,34 +539,87 @@ fn encode_image_to_url(image: &Image) -> EcoString { VectorFormat::Svg => "svg+xml", }, }; - let mut url = eco_format!("data:image/{};base64,", format); + + let mut url = eco_format!("data:image/{format};base64,"); 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 { +/// 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 Deduplicator<T> { + kind: char, + vec: Vec<T>, + present: HashMap<u128, Id>, +} + +impl<T> Deduplicator<T> { + fn new(kind: char) -> Self { + Self { kind, vec: Vec::new(), present: HashMap::new() } + } + + /// Inserts a value into the vector. If the hash is already present, returns + /// the index of the existing value and `f` will not be called. Otherwise, + /// inserts the value and returns the id of the inserted value. + #[must_use = "returns the index of the inserted value"] + fn insert_with<F>(&mut self, hash: u128, f: F) -> Id + where + F: FnOnce() -> T, + { + *self.present.entry(hash).or_insert_with(|| { + let index = self.vec.len(); + self.vec.push(f()); + Id(self.kind, index) + }) + } + + /// Iterate over the the elements alongside their ids. + fn iter(&self) -> impl Iterator<Item = (Id, &T)> { + self.vec.iter().enumerate().map(|(i, v)| (Id(self.kind, i), v)) + } +} + +/// Identifies a `<def>`. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct Id(char, usize); + +impl Display for Id { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}{}", self.0, self.1) + } +} + +/// Displays as an SVG matrix. +struct SvgMatrix(Transform); + +impl Display for SvgMatrix { + fn fmt(&self, f: &mut Formatter) -> 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() + self.0.sx.get(), + self.0.ky.get(), + self.0.kx.get(), + self.0.sy.get(), + self.0.tx.to_pt(), + self.0.ty.to_pt() ) } } + /// A builder for SVG path. -struct SVGPath2DBuilder(pub EcoString); +#[derive(Default)] +struct SvgPathBuilder(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. +impl SvgPathBuilder { + /// 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); @@ -602,7 +630,7 @@ impl SVGPath2DBuilder { } /// A builder for SVG path. This is used to build the path for a glyph. -impl ttf_parser::OutlineBuilder for SVGPath2DBuilder { +impl ttf_parser::OutlineBuilder for SvgPathBuilder { fn move_to(&mut self, x: f32, y: f32) { write!(&mut self.0, "M {} {} ", x, y).unwrap(); } diff --git a/tests/src/tests.rs b/tests/src/tests.rs index b5f80663..16292ef2 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -429,7 +429,7 @@ fn test( fs::create_dir_all(png_path.parent().unwrap()).unwrap(); canvas.save_png(png_path).unwrap(); - let svg = typst::export::svg(&document); + let svg = typst::export::svg_merged(&document.pages, Abs::pt(5.0)); fs::create_dir_all(svg_path.parent().unwrap()).unwrap(); std::fs::write(svg_path, svg).unwrap(); @@ -898,42 +898,33 @@ fn test_spans_impl(output: &mut String, node: &SyntaxNode, within: Range<u64>) - /// Draw all frames into one image with padding in between. fn render(frames: &[Frame]) -> sk::Pixmap { let pixel_per_pt = 2.0; - let pixmaps: Vec<_> = frames - .iter() - .map(|frame| { - let limit = Abs::cm(100.0); - if frame.width() > limit || frame.height() > limit { - panic!("overlarge frame: {:?}", frame.size()); - } - typst::export::render(frame, pixel_per_pt, Color::WHITE) - }) - .collect(); + let padding = Abs::pt(5.0); - let pad = (5.0 * pixel_per_pt).round() as u32; - let pxw = 2 * pad + pixmaps.iter().map(sk::Pixmap::width).max().unwrap_or_default(); - let pxh = pad + pixmaps.iter().map(|pixmap| pixmap.height() + pad).sum::<u32>(); + for frame in frames { + let limit = Abs::cm(100.0); + if frame.width() > limit || frame.height() > limit { + panic!("overlarge frame: {:?}", frame.size()); + } + } - let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap(); - canvas.fill(sk::Color::BLACK); + let mut pixmap = typst::export::render_merged( + frames, + pixel_per_pt, + Color::WHITE, + padding, + Color::BLACK, + ); - let [x, mut y] = [pad; 2]; - for (frame, mut pixmap) in frames.iter().zip(pixmaps) { - let ts = sk::Transform::from_scale(pixel_per_pt, pixel_per_pt); + let padding = (pixel_per_pt * padding.to_pt() as f32).round(); + let [x, mut y] = [padding; 2]; + for frame in frames { + let ts = + sk::Transform::from_scale(pixel_per_pt, pixel_per_pt).post_translate(x, y); render_links(&mut pixmap, ts, frame); - - canvas.draw_pixmap( - x as i32, - y as i32, - pixmap.as_ref(), - &sk::PixmapPaint::default(), - sk::Transform::identity(), - None, - ); - - y += pixmap.height() + pad; + y += (pixel_per_pt * frame.height().to_pt() as f32).round().max(1.0) + padding; } - canvas + pixmap } /// Draw extra boxes for links so we can see whether they are there. |
