diff --git a/Cargo.lock b/Cargo.lock index 99295d902..72f757cdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2600,6 +2600,7 @@ dependencies = [ "unscanny", "usvg", "wasmi", + "xmlwriter", ] [[package]] @@ -2657,7 +2658,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.11.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=ee8ae61cca138dc92f9d818fc7f2fc046d0148c5#ee8ae61cca138dc92f9d818fc7f2fc046d0148c5" +source = "git+https://github.com/typst/typst-dev-assets?rev=48a924d9de82b631bc775124a69384c8d860db04#48a924d9de82b631bc775124a69384c8d860db04" [[package]] name = "typst-docs" diff --git a/Cargo.toml b/Cargo.toml index 40051a5a6..367e835a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.11.0" } typst-timing = { path = "crates/typst-timing", version = "0.11.0" } typst-utils = { path = "crates/typst-utils", version = "0.11.0" } typst-assets = "0.11.0" -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "ee8ae61cca138dc92f9d818fc7f2fc046d0148c5" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "48a924d9de82b631bc775124a69384c8d860db04" } az = "1.2" base64 = "0.22" bitflags = { version = "2", features = ["serde"] } diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index 3934ff42b..b90780377 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -68,6 +68,7 @@ unicode-script = { workspace = true } unicode-segmentation = { workspace = true } unscanny = { workspace = true } usvg = { workspace = true } +xmlwriter = { workspace = true } wasmi = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/typst/src/text/font/color.rs b/crates/typst/src/text/font/color.rs index ceddeeae7..239fbdeeb 100644 --- a/crates/typst/src/text/font/color.rs +++ b/crates/typst/src/text/font/color.rs @@ -2,13 +2,14 @@ use std::io::Read; -use ecow::EcoString; -use ttf_parser::GlyphId; +use ttf_parser::{GlyphId, RgbaColor}; +use usvg::tiny_skia_path; +use xmlwriter::XmlWriter; -use crate::layout::{Abs, Axes, Em, Frame, FrameItem, Point, Size}; +use crate::layout::{Abs, Axes, Frame, FrameItem, Point, Size}; use crate::syntax::Span; -use crate::text::{Font, Glyph, Lang, TextItem}; -use crate::visualize::{Color, Image, Rgb}; +use crate::text::{Font, Glyph}; +use crate::visualize::Image; /// Tells if a glyph is a color glyph or not in a given font. pub fn is_color_glyph(font: &Font, g: &Glyph) -> bool { @@ -33,15 +34,81 @@ pub fn frame_for_glyph(font: &Font, glyph_id: u16) -> Frame { if let Some(raster_image) = ttf.glyph_raster_image(glyph_id, u16::MAX) { draw_raster_glyph(&mut frame, font, upem, raster_image); + } else if ttf.is_color_glyph(glyph_id) { + draw_colr_glyph(&mut frame, upem, ttf, glyph_id); } else if ttf.glyph_svg_image(glyph_id).is_some() { draw_svg_glyph(&mut frame, upem, font, glyph_id); - } else if ttf.is_color_glyph(glyph_id) { - draw_colr_glyph(&mut frame, font, glyph_id); } frame } +fn draw_colr_glyph( + frame: &mut Frame, + upem: Abs, + ttf: &ttf_parser::Face, + glyph_id: GlyphId, +) -> Option<()> { + let mut svg = XmlWriter::new(xmlwriter::Options::default()); + + let width = ttf.global_bounding_box().width() as f64; + let height = ttf.global_bounding_box().height() as f64; + let x_min = ttf.global_bounding_box().x_min as f64; + let y_max = ttf.global_bounding_box().y_max as f64; + let tx = -x_min; + let ty = -y_max; + + svg.start_element("svg"); + svg.write_attribute("xmlns", "http://www.w3.org/2000/svg"); + svg.write_attribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + svg.write_attribute("width", &width); + svg.write_attribute("height", &height); + svg.write_attribute_fmt("viewBox", format_args!("0 0 {width} {height}")); + + let mut path_buf = String::with_capacity(256); + let gradient_index = 1; + let clip_path_index = 1; + + svg.start_element("g"); + svg.write_attribute_fmt( + "transform", + format_args!("matrix(1 0 0 -1 0 0) matrix(1 0 0 1 {tx} {ty})"), + ); + + let mut glyph_painter = GlyphPainter { + face: ttf, + svg: &mut svg, + path_buf: &mut path_buf, + gradient_index, + clip_path_index, + palette_index: 0, + transform: ttf_parser::Transform::default(), + outline_transform: ttf_parser::Transform::default(), + transforms_stack: vec![ttf_parser::Transform::default()], + }; + + ttf.paint_color_glyph(glyph_id, 0, RgbaColor::new(0, 0, 0, 255), &mut glyph_painter) + .unwrap(); + svg.end_element(); + + let data = svg.end_document().into_bytes(); + + let image = Image::new( + data.into(), + typst::visualize::ImageFormat::Vector(typst::visualize::VectorFormat::Svg), + None, + ) + .unwrap(); + + let y_shift = Abs::raw(upem.to_raw() - y_max); + + let position = Point::new(Abs::raw(x_min), y_shift); + let size = Axes::new(Abs::pt(width), Abs::pt(height)); + frame.push(position, FrameItem::Image(image, size, Span::detached())); + + Some(()) +} + /// Draws a raster glyph in a frame. fn draw_raster_glyph( frame: &mut Frame, @@ -74,77 +141,6 @@ fn draw_raster_glyph( frame.push(position, FrameItem::Image(image, size, Span::detached())); } -/// Draws a COLR glyph in a frame. -fn draw_colr_glyph(frame: &mut Frame, font: &Font, glyph_id: GlyphId) { - let mut painter = ColrPainter { font, current_glyph: glyph_id, frame }; - let black = ttf_parser::RgbaColor::new(0, 0, 0, 255); - font.ttf().paint_color_glyph(glyph_id, 0, black, &mut painter); -} - -/// Draws COLR glyphs in a frame. -struct ColrPainter<'f, 't> { - /// The frame in which to draw. - frame: &'f mut Frame, - /// The font of the text. - font: &'t Font, - /// The glyph that will be drawn the next time `ColrPainter::paint` is called. - current_glyph: GlyphId, -} - -impl<'f, 't> ttf_parser::colr::Painter<'_> for ColrPainter<'f, 't> { - fn outline_glyph(&mut self, glyph_id: GlyphId) { - self.current_glyph = glyph_id; - } - - fn paint(&mut self, paint: ttf_parser::colr::Paint) { - let ttf_parser::colr::Paint::Solid(color) = paint else { return }; - let color = Color::Rgb(Rgb::new( - color.red as f32 / 255.0, - color.green as f32 / 255.0, - color.blue as f32 / 255.0, - color.alpha as f32 / 255.0, - )); - - self.frame.push( - // With images, the position corresponds to the top-left corner, but - // in the case of text it matches the baseline-left point. Here, we - // move the glyph one unit down to compensate for that. - Point::new(Abs::zero(), Abs::pt(self.font.units_per_em())), - FrameItem::Text(TextItem { - font: self.font.clone(), - size: Abs::pt(self.font.units_per_em()), - fill: color.into(), - stroke: None, - lang: Lang::ENGLISH, - region: None, - text: EcoString::new(), - glyphs: vec![Glyph { - id: self.current_glyph.0, - // Advance is not relevant here as we will draw glyph on top - // of each other anyway - x_advance: Em::zero(), - x_offset: Em::zero(), - range: 0..0, - span: (Span::detached(), 0), - }], - }), - ); - } - - // These are not implemented. - fn push_clip(&mut self) {} - fn push_clip_box(&mut self, _: ttf_parser::colr::ClipBox) {} - fn pop_clip(&mut self) {} - fn push_layer(&mut self, _: ttf_parser::colr::CompositeMode) {} - fn pop_layer(&mut self) {} - fn push_translate(&mut self, _: f32, _: f32) {} - fn push_scale(&mut self, _: f32, _: f32) {} - fn push_rotate(&mut self, _: f32) {} - fn push_skew(&mut self, _: f32, _: f32) {} - fn push_transform(&mut self, _: ttf_parser::Transform) {} - fn pop_transform(&mut self) {} -} - /// Draws an SVG glyph in a frame. fn draw_svg_glyph( frame: &mut Frame, @@ -152,6 +148,8 @@ fn draw_svg_glyph( font: &Font, glyph_id: GlyphId, ) -> Option<()> { + // TODO: Our current conversion of the SVG table works for Twitter Color Emoji, + // but might not work for others. See also: https://github.com/RazrFalcon/resvg/pull/776 let mut data = font.ttf().glyph_svg_image(glyph_id)?.data; // Decompress SVGZ. @@ -266,3 +264,342 @@ fn make_svg_unsized(svg: &mut String) { svg.replace_range(range, ""); } } + +struct ColrBuilder<'a>(&'a mut String); + +impl ColrBuilder<'_> { + fn finish(&mut self) { + if !self.0.is_empty() { + self.0.pop(); // remove trailing space + } + } +} + +impl ttf_parser::OutlineBuilder for ColrBuilder<'_> { + fn move_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "M {x} {y} ").unwrap() + } + + fn line_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "L {x} {y} ").unwrap() + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "Q {x1} {y1} {x} {y} ").unwrap() + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.0, "C {x1} {y1} {x2} {y2} {x} {y} ").unwrap() + } + + fn close(&mut self) { + self.0.push_str("Z ") + } +} + +// NOTE: This is only a best-effort translation of COLR into SVG. It's not feature-complete +// and it's also not possible to make it feature-complete using just raw SVG features. +pub(crate) struct GlyphPainter<'a> { + pub(crate) face: &'a ttf_parser::Face<'a>, + pub(crate) svg: &'a mut xmlwriter::XmlWriter, + pub(crate) path_buf: &'a mut String, + pub(crate) gradient_index: usize, + pub(crate) clip_path_index: usize, + pub(crate) palette_index: u16, + pub(crate) transform: ttf_parser::Transform, + pub(crate) outline_transform: ttf_parser::Transform, + pub(crate) transforms_stack: Vec, +} + +impl<'a> GlyphPainter<'a> { + fn write_gradient_stops(&mut self, stops: ttf_parser::colr::GradientStopsIter) { + for stop in stops { + self.svg.start_element("stop"); + self.svg.write_attribute("offset", &stop.stop_offset); + self.write_color_attribute("stop-color", stop.color); + let opacity = f32::from(stop.color.alpha) / 255.0; + self.svg.write_attribute("stop-opacity", &opacity); + self.svg.end_element(); + } + } + + fn write_color_attribute(&mut self, name: &str, color: ttf_parser::RgbaColor) { + self.svg.write_attribute_fmt( + name, + format_args!("rgb({}, {}, {})", color.red, color.green, color.blue), + ); + } + + fn write_transform_attribute(&mut self, name: &str, ts: ttf_parser::Transform) { + if ts.is_default() { + return; + } + + self.svg.write_attribute_fmt( + name, + format_args!("matrix({} {} {} {} {} {})", ts.a, ts.b, ts.c, ts.d, ts.e, ts.f), + ); + } + + fn write_spread_method_attribute( + &mut self, + extend: ttf_parser::colr::GradientExtend, + ) { + self.svg.write_attribute( + "spreadMethod", + match extend { + ttf_parser::colr::GradientExtend::Pad => &"pad", + ttf_parser::colr::GradientExtend::Repeat => &"repeat", + ttf_parser::colr::GradientExtend::Reflect => &"reflect", + }, + ); + } + + fn paint_solid(&mut self, color: ttf_parser::RgbaColor) { + self.svg.start_element("path"); + self.write_color_attribute("fill", color); + let opacity = f32::from(color.alpha) / 255.0; + self.svg.write_attribute("fill-opacity", &opacity); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_linear_gradient(&mut self, gradient: ttf_parser::colr::LinearGradient<'a>) { + let gradient_id = format!("lg{}", self.gradient_index); + self.gradient_index += 1; + + let gradient_transform = paint_transform(self.outline_transform, self.transform); + + // TODO: We ignore x2, y2. Have to apply them somehow. + // TODO: The way spreadMode works in ttf and svg is a bit different. In SVG, the spreadMode + // will always be applied based on x1/y1 and x2/y2. However, in TTF the spreadMode will + // be applied from the first/last stop. So if we have a gradient with x1=0 x2=1, and + // a stop at x=0.4 and x=0.6, then in SVG we will always see a padding, while in ttf + // we will see the actual spreadMode. We need to account for that somehow. + self.svg.start_element("linearGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("x1", &gradient.x0); + self.svg.write_attribute("y1", &gradient.y0); + self.svg.write_attribute("x2", &gradient.x1); + self.svg.write_attribute("y2", &gradient.y1); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method_attribute(gradient.extend); + self.write_transform_attribute("gradientTransform", gradient_transform); + self.write_gradient_stops( + gradient.stops(self.palette_index, self.face.variation_coordinates()), + ); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{gradient_id})")); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_radial_gradient(&mut self, gradient: ttf_parser::colr::RadialGradient<'a>) { + let gradient_id = format!("rg{}", self.gradient_index); + self.gradient_index += 1; + + let gradient_transform = paint_transform(self.outline_transform, self.transform); + + self.svg.start_element("radialGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("cx", &gradient.x1); + self.svg.write_attribute("cy", &gradient.y1); + self.svg.write_attribute("r", &gradient.r1); + self.svg.write_attribute("fr", &gradient.r0); + self.svg.write_attribute("fx", &gradient.x0); + self.svg.write_attribute("fy", &gradient.y0); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method_attribute(gradient.extend); + self.write_transform_attribute("gradientTransform", gradient_transform); + self.write_gradient_stops( + gradient.stops(self.palette_index, self.face.variation_coordinates()), + ); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{gradient_id})")); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_sweep_gradient(&mut self, _: ttf_parser::colr::SweepGradient<'a>) {} +} + +fn paint_transform( + outline_transform: ttf_parser::Transform, + transform: ttf_parser::Transform, +) -> ttf_parser::Transform { + let outline_transform = tiny_skia_path::Transform::from_row( + outline_transform.a, + outline_transform.b, + outline_transform.c, + outline_transform.d, + outline_transform.e, + outline_transform.f, + ); + + let gradient_transform = tiny_skia_path::Transform::from_row( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ); + + let gradient_transform = outline_transform + .invert() + // In theory, we should error out. But the transform shouldn't ever be uninvertible, so let's ignore it. + .unwrap_or_default() + .pre_concat(gradient_transform); + + ttf_parser::Transform { + a: gradient_transform.sx, + b: gradient_transform.ky, + c: gradient_transform.kx, + d: gradient_transform.sy, + e: gradient_transform.tx, + f: gradient_transform.ty, + } +} + +impl GlyphPainter<'_> { + fn clip_with_path(&mut self, path: &str) { + let clip_id = format!("cp{}", self.clip_path_index); + self.clip_path_index += 1; + + self.svg.start_element("clipPath"); + self.svg.write_attribute("id", &clip_id); + self.svg.start_element("path"); + self.write_transform_attribute("transform", self.outline_transform); + self.svg.write_attribute("d", &path); + self.svg.end_element(); + self.svg.end_element(); + + self.svg.start_element("g"); + self.svg + .write_attribute_fmt("clip-path", format_args!("url(#{clip_id})")); + } +} + +impl<'a> ttf_parser::colr::Painter<'a> for GlyphPainter<'a> { + fn outline_glyph(&mut self, glyph_id: ttf_parser::GlyphId) { + self.path_buf.clear(); + let mut builder = ColrBuilder(self.path_buf); + match self.face.outline_glyph(glyph_id, &mut builder) { + Some(v) => v, + None => return, + }; + builder.finish(); + + // We have to write outline using the current transform. + self.outline_transform = self.transform; + } + + fn push_layer(&mut self, mode: ttf_parser::colr::CompositeMode) { + self.svg.start_element("g"); + + use ttf_parser::colr::CompositeMode; + // TODO: Need to figure out how to represent the other blend modes + // in SVG. + let mode = match mode { + CompositeMode::SourceOver => "normal", + CompositeMode::Screen => "screen", + CompositeMode::Overlay => "overlay", + CompositeMode::Darken => "darken", + CompositeMode::Lighten => "lighten", + CompositeMode::ColorDodge => "color-dodge", + CompositeMode::ColorBurn => "color-burn", + CompositeMode::HardLight => "hard-light", + CompositeMode::SoftLight => "soft-light", + CompositeMode::Difference => "difference", + CompositeMode::Exclusion => "exclusion", + CompositeMode::Multiply => "multiply", + CompositeMode::Hue => "hue", + CompositeMode::Saturation => "saturation", + CompositeMode::Color => "color", + CompositeMode::Luminosity => "luminosity", + _ => "normal", + }; + self.svg.write_attribute_fmt( + "style", + format_args!("mix-blend-mode: {mode}; isolation: isolate"), + ); + } + + fn pop_layer(&mut self) { + self.svg.end_element(); // g + } + + fn push_translate(&mut self, tx: f32, ty: f32) { + self.push_transform(ttf_parser::Transform::new(1.0, 0.0, 0.0, 1.0, tx, ty)); + } + + fn push_scale(&mut self, sx: f32, sy: f32) { + self.push_transform(ttf_parser::Transform::new(sx, 0.0, 0.0, sy, 0.0, 0.0)); + } + + fn push_rotate(&mut self, angle: f32) { + let cc = (angle * std::f32::consts::PI).cos(); + let ss = (angle * std::f32::consts::PI).sin(); + self.push_transform(ttf_parser::Transform::new(cc, ss, -ss, cc, 0.0, 0.0)); + } + + fn push_skew(&mut self, skew_x: f32, skew_y: f32) { + let x = (-skew_x * std::f32::consts::PI).tan(); + let y = (skew_y * std::f32::consts::PI).tan(); + self.push_transform(ttf_parser::Transform::new(1.0, y, x, 1.0, 0.0, 0.0)); + } + + fn push_transform(&mut self, transform: ttf_parser::Transform) { + self.transforms_stack.push(self.transform); + self.transform = ttf_parser::Transform::combine(self.transform, transform); + } + + fn paint(&mut self, paint: ttf_parser::colr::Paint<'a>) { + match paint { + ttf_parser::colr::Paint::Solid(color) => self.paint_solid(color), + ttf_parser::colr::Paint::LinearGradient(lg) => self.paint_linear_gradient(lg), + ttf_parser::colr::Paint::RadialGradient(rg) => self.paint_radial_gradient(rg), + ttf_parser::colr::Paint::SweepGradient(sg) => self.paint_sweep_gradient(sg), + } + } + + fn pop_transform(&mut self) { + if let Some(ts) = self.transforms_stack.pop() { + self.transform = ts + } + } + + fn push_clip(&mut self) { + self.clip_with_path(&self.path_buf.clone()); + } + + fn pop_clip(&mut self) { + self.svg.end_element(); + } + + fn push_clip_box(&mut self, clipbox: ttf_parser::colr::ClipBox) { + let x_min = clipbox.x_min; + let x_max = clipbox.x_max; + let y_min = clipbox.y_min; + let y_max = clipbox.y_max; + + let clip_path = format!( + "M {x_min} {y_min} L {x_max} {y_min} L {x_max} {y_max} L {x_min} {y_max} Z" + ); + + self.clip_with_path(&clip_path); + } +} diff --git a/tests/ref/issue-3733-dpi-svg.png b/tests/ref/issue-3733-dpi-svg.png new file mode 100644 index 000000000..15b0cc52c Binary files /dev/null and b/tests/ref/issue-3733-dpi-svg.png differ diff --git a/tests/ref/shaping-emoji-basic.png b/tests/ref/shaping-emoji-basic.png index 9874ccdec..ecec33491 100644 Binary files a/tests/ref/shaping-emoji-basic.png and b/tests/ref/shaping-emoji-basic.png differ diff --git a/tests/ref/shaping-font-fallback.png b/tests/ref/shaping-font-fallback.png index fc5e0bff3..27f09230f 100644 Binary files a/tests/ref/shaping-font-fallback.png and b/tests/ref/shaping-font-fallback.png differ diff --git a/tests/suite/visualize/image.typ b/tests/suite/visualize/image.typ index ac2d5af90..3bda8d0ee 100644 --- a/tests/suite/visualize/image.typ +++ b/tests/suite/visualize/image.typ @@ -120,3 +120,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B --- issue-2051-new-cm-svg --- #set text(font: "New Computer Modern") #image("/assets/images/diagram.svg") + +--- issue-3733-dpi-svg --- +#set page(width: 200pt, height: 200pt, margin: 0pt) +#image("/assets/images/relative.svg")