summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2023-08-08 12:59:21 +0200
committerLaurenz <laurmaedje@gmail.com>2023-08-08 13:00:47 +0200
commit2ea451b83b919e5ed59ad203bb22efa844660bd1 (patch)
tree96b718c4e313c4a2ef12368cef5164df64c118cd
parent61e4ad6bbafaa97b965a206ad06af65a0805be7e (diff)
Refactor SVG export a bit
-rw-r--r--crates/typst-cli/src/compile.rs25
-rw-r--r--crates/typst/src/export/mod.rs4
-rw-r--r--crates/typst/src/export/render.rs41
-rw-r--r--crates/typst/src/export/svg.rs796
-rw-r--r--tests/src/tests.rs53
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.