mirror of
https://github.com/typst/typst
synced 2025-05-14 04:56:26 +08:00
Refactor SVG export a bit
This commit is contained in:
parent
61e4ad6bba
commit
2ea451b83b
@ -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")?;
|
||||
}
|
||||
}
|
||||
|
@ -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};
|
||||
|
@ -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,
|
||||
|
@ -1,149 +1,90 @@
|
||||
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.
|
||||
/// 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);
|
||||
|
||||
let mut renderer = SVGRenderer::new();
|
||||
renderer.write_header(size);
|
||||
|
||||
let [x, mut y] = [padding; 2];
|
||||
for frame in frames {
|
||||
renderer.render_frame(frame, Transform::translate(x, y));
|
||||
y += frame.height() + padding;
|
||||
}
|
||||
|
||||
renderer.finalize()
|
||||
}
|
||||
|
||||
/// Renders one or multiple frames to an SVG file.
|
||||
struct SVGRenderer {
|
||||
/// The internal XML writer.
|
||||
xml: XmlWriter,
|
||||
/// 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,`.
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
/// 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<Item = &T> {
|
||||
self.vec.iter()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`SVGRenderer`] is a renderer that renders a document or frame into a SVG file.
|
||||
struct SVGRenderer {
|
||||
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'>,
|
||||
}
|
||||
|
||||
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(),
|
||||
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.
|
||||
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());
|
||||
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,49 +250,61 @@ 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.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(
|
||||
@ -500,48 +337,10 @@ impl SVGRenderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[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()
|
||||
}
|
||||
|
||||
/// Encode an image into a data URL. The format of the URL is `data:image/{format};base64,`.
|
||||
/// 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 encode_image_to_url(image: &Image) -> EcoString {
|
||||
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,`.
|
||||
#[comemo::memoize]
|
||||
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);
|
||||
|
||||
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.
|
||||
/// A builder for SVG path.
|
||||
#[derive(Default)]
|
||||
struct SvgPathBuilder(pub EcoString);
|
||||
|
||||
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();
|
||||
}
|
||||
|
@ -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 padding = Abs::pt(5.0);
|
||||
|
||||
for frame in frames {
|
||||
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 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>();
|
||||
|
||||
let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap();
|
||||
canvas.fill(sk::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);
|
||||
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;
|
||||
}
|
||||
|
||||
canvas
|
||||
let mut pixmap = typst::export::render_merged(
|
||||
frames,
|
||||
pixel_per_pt,
|
||||
Color::WHITE,
|
||||
padding,
|
||||
Color::BLACK,
|
||||
);
|
||||
|
||||
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);
|
||||
y += (pixel_per_pt * frame.height().to_pt() as f32).round().max(1.0) + padding;
|
||||
}
|
||||
|
||||
pixmap
|
||||
}
|
||||
|
||||
/// Draw extra boxes for links so we can see whether they are there.
|
||||
|
Loading…
x
Reference in New Issue
Block a user