summaryrefslogtreecommitdiff
path: root/crates/typst-render
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-render')
-rw-r--r--crates/typst-render/src/image.rs86
-rw-r--r--crates/typst-render/src/lib.rs800
-rw-r--r--crates/typst-render/src/paint.rs267
-rw-r--r--crates/typst-render/src/shape.rs174
-rw-r--r--crates/typst-render/src/text.rs281
5 files changed, 821 insertions, 787 deletions
diff --git a/crates/typst-render/src/image.rs b/crates/typst-render/src/image.rs
new file mode 100644
index 00000000..81b219de
--- /dev/null
+++ b/crates/typst-render/src/image.rs
@@ -0,0 +1,86 @@
+use std::sync::Arc;
+
+use image::imageops::FilterType;
+use image::{GenericImageView, Rgba};
+use tiny_skia as sk;
+use typst::layout::Size;
+use typst::visualize::{Image, ImageKind};
+
+use crate::{AbsExt, State};
+
+/// Render a raster or SVG image into the canvas.
+pub fn render_image(
+ canvas: &mut sk::Pixmap,
+ state: State,
+ image: &Image,
+ size: Size,
+) -> Option<()> {
+ let ts = state.transform;
+ let view_width = size.x.to_f32();
+ let view_height = size.y.to_f32();
+
+ // For better-looking output, resize `image` to its final size before
+ // painting it to `canvas`. For the math, see:
+ // https://github.com/typst/typst/issues/1404#issuecomment-1598374652
+ let theta = f32::atan2(-ts.kx, ts.sx);
+
+ // To avoid division by 0, choose the one of { sin, cos } that is
+ // further from 0.
+ let prefer_sin = theta.sin().abs() > std::f32::consts::FRAC_1_SQRT_2;
+ let scale_x =
+ f32::abs(if prefer_sin { ts.kx / theta.sin() } else { ts.sx / theta.cos() });
+
+ let aspect = (image.width() as f32) / (image.height() as f32);
+ let w = (scale_x * view_width.max(aspect * view_height)).ceil() as u32;
+ let h = ((w as f32) / aspect).ceil() as u32;
+
+ let pixmap = scaled_texture(image, w, h)?;
+ let paint_scale_x = view_width / pixmap.width() as f32;
+ let paint_scale_y = view_height / pixmap.height() as f32;
+
+ let paint = sk::Paint {
+ shader: sk::Pattern::new(
+ (*pixmap).as_ref(),
+ sk::SpreadMode::Pad,
+ sk::FilterQuality::Nearest,
+ 1.0,
+ sk::Transform::from_scale(paint_scale_x, paint_scale_y),
+ ),
+ ..Default::default()
+ };
+
+ let rect = sk::Rect::from_xywh(0.0, 0.0, view_width, view_height)?;
+ canvas.fill_rect(rect, &paint, ts, state.mask);
+
+ Some(())
+}
+
+/// Prepare a texture for an image at a scaled size.
+#[comemo::memoize]
+fn scaled_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> {
+ let mut pixmap = sk::Pixmap::new(w, h)?;
+ match image.kind() {
+ ImageKind::Raster(raster) => {
+ let downscale = w < raster.width();
+ let filter =
+ if downscale { FilterType::Lanczos3 } else { FilterType::CatmullRom };
+ let buf = raster.dynamic().resize(w, h, filter);
+ for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) {
+ let Rgba([r, g, b, a]) = src;
+ *dest = sk::ColorU8::from_rgba(r, g, b, a).premultiply();
+ }
+ }
+ // Safety: We do not keep any references to tree nodes beyond the scope
+ // of `with`.
+ ImageKind::Svg(svg) => unsafe {
+ svg.with(|tree| {
+ let ts = tiny_skia::Transform::from_scale(
+ w as f32 / tree.size.width(),
+ h as f32 / tree.size.height(),
+ );
+ resvg::render(tree, ts, &mut pixmap.as_mut())
+ });
+ },
+ }
+ Some(Arc::new(pixmap))
+}
diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs
index 401c7026..08c80050 100644
--- a/crates/typst-render/src/lib.rs
+++ b/crates/typst-render/src/lib.rs
@@ -1,23 +1,17 @@
//! Rendering of Typst documents into raster images.
-use std::sync::Arc;
+mod image;
+mod paint;
+mod shape;
+mod text;
-use image::imageops::FilterType;
-use image::{GenericImageView, Rgba};
-use pixglyph::Bitmap;
use tiny_skia as sk;
-use ttf_parser::{GlyphId, OutlineBuilder};
use typst::introspection::Meta;
use typst::layout::{
- Abs, Axes, Frame, FrameItem, FrameKind, GroupItem, Point, Ratio, Size, Transform,
+ Abs, Axes, Frame, FrameItem, FrameKind, GroupItem, Point, Size, Transform,
};
use typst::model::Document;
-use typst::text::color::{frame_for_glyph, is_color_glyph};
-use typst::text::{Font, TextItem};
-use typst::visualize::{
- Color, DashPattern, FixedStroke, Geometry, Gradient, Image, ImageKind, LineCap,
- LineJoin, Paint, Path, PathItem, Pattern, RelativeTo, Shape,
-};
+use typst::visualize::Color;
/// Export a frame into a raster image.
///
@@ -30,7 +24,7 @@ pub fn render(frame: &Frame, pixel_per_pt: f32, fill: Color) -> sk::Pixmap {
let pxh = (pixel_per_pt * size.y.to_f32()).round().max(1.0) as u32;
let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap();
- canvas.fill(to_sk_color(fill));
+ canvas.fill(paint::to_sk_color(fill));
let ts = sk::Transform::from_scale(pixel_per_pt, pixel_per_pt);
render_frame(&mut canvas, State::new(size, ts, pixel_per_pt), frame);
@@ -60,7 +54,7 @@ pub fn render_merged(
+ gap * pixmaps.len().saturating_sub(1) as u32;
let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap();
- canvas.fill(to_sk_color(gap_fill));
+ canvas.fill(paint::to_sk_color(gap_fill));
let mut y = 0;
for pixmap in pixmaps {
@@ -160,13 +154,13 @@ fn render_frame(canvas: &mut sk::Pixmap, state: State, frame: &Frame) {
render_group(canvas, state, *pos, group);
}
FrameItem::Text(text) => {
- render_text(canvas, state.pre_translate(*pos), text);
+ text::render_text(canvas, state.pre_translate(*pos), text);
}
FrameItem::Shape(shape, _) => {
- render_shape(canvas, state.pre_translate(*pos), shape);
+ shape::render_shape(canvas, state.pre_translate(*pos), shape);
}
FrameItem::Image(image, size, _) => {
- render_image(canvas, state.pre_translate(*pos), image, *size);
+ image::render_image(canvas, state.pre_translate(*pos), image, *size);
}
FrameItem::Meta(meta, _) => match meta {
Meta::Link(_) => {}
@@ -198,8 +192,8 @@ fn render_group(canvas: &mut sk::Pixmap, state: State, pos: Point, group: &Group
let mut mask = state.mask;
let storage;
if let Some(clip_path) = group.clip_path.as_ref() {
- if let Some(path) =
- convert_path(clip_path).and_then(|path| path.transform(state.transform))
+ if let Some(path) = shape::convert_path(clip_path)
+ .and_then(|path| path.transform(state.transform))
{
if let Some(mask) = mask {
let mut mask = mask.clone();
@@ -235,719 +229,6 @@ fn render_group(canvas: &mut sk::Pixmap, state: State, pos: Point, group: &Group
render_frame(canvas, state.with_mask(mask), &group.frame);
}
-/// Render a text run into the canvas.
-fn render_text(canvas: &mut sk::Pixmap, state: State, text: &TextItem) {
- let mut x = 0.0;
- for glyph in &text.glyphs {
- let id = GlyphId(glyph.id);
- let offset = x + glyph.x_offset.at(text.size).to_f32();
-
- if is_color_glyph(&text.font, glyph) {
- let upem = text.font.units_per_em();
- let text_scale = Abs::raw(text.size.to_raw() / upem);
- let state = state
- .pre_translate(Point::new(Abs::raw(offset as _), -text.size))
- .pre_scale(Axes::new(text_scale, text_scale));
-
- let glyph_frame = frame_for_glyph(&text.font, glyph.id);
-
- render_frame(canvas, state, &glyph_frame);
- } else {
- let state =
- state.pre_translate(Point::new(Abs::raw(offset as _), Abs::raw(0.0)));
- render_outline_glyph(canvas, state, text, id);
- }
-
- x += glyph.x_advance.at(text.size).to_f32();
- }
-}
-
-/// Render an outline glyph into the canvas. This is the "normal" case.
-fn render_outline_glyph(
- canvas: &mut sk::Pixmap,
- state: State,
- text: &TextItem,
- id: GlyphId,
-) -> Option<()> {
- let ts = &state.transform;
- let ppem = text.size.to_f32() * ts.sy;
-
- // Render a glyph directly as a path. This only happens when the fast glyph
- // rasterization can't be used due to very large text size or weird
- // scale/skewing transforms.
- if ppem > 100.0
- || ts.kx != 0.0
- || ts.ky != 0.0
- || ts.sx != ts.sy
- || text.stroke.is_some()
- {
- let path = {
- let mut builder = WrappedPathBuilder(sk::PathBuilder::new());
- text.font.ttf().outline_glyph(id, &mut builder)?;
- builder.0.finish()?
- };
-
- let scale = text.size.to_f32() / text.font.units_per_em() as f32;
-
- let mut pixmap = None;
-
- let rule = sk::FillRule::default();
-
- // Flip vertically because font design coordinate
- // system is Y-up.
- let ts = ts.pre_scale(scale, -scale);
- let state_ts = state.pre_concat(sk::Transform::from_scale(scale, -scale));
- let paint = to_sk_paint(
- &text.fill,
- state_ts,
- Size::zero(),
- true,
- None,
- &mut pixmap,
- None,
- );
- canvas.fill_path(&path, &paint, rule, ts, state.mask);
-
- if let Some(FixedStroke { paint, thickness, cap, join, dash, miter_limit }) =
- &text.stroke
- {
- if thickness.to_f32() > 0.0 {
- let dash = dash.as_ref().and_then(to_sk_dash_pattern);
-
- let paint = to_sk_paint(
- paint,
- state_ts,
- Size::zero(),
- true,
- None,
- &mut pixmap,
- None,
- );
- let stroke = sk::Stroke {
- width: thickness.to_f32() / scale, // When we scale the path, we need to scale the stroke width, too.
- line_cap: to_sk_line_cap(*cap),
- line_join: to_sk_line_join(*join),
- dash,
- miter_limit: miter_limit.get() as f32,
- };
-
- canvas.stroke_path(&path, &paint, &stroke, ts, state.mask);
- }
- }
- return Some(());
- }
-
- // Rasterize the glyph with `pixglyph`.
- #[comemo::memoize]
- fn rasterize(
- font: &Font,
- id: GlyphId,
- x: u32,
- y: u32,
- size: u32,
- ) -> Option<Arc<Bitmap>> {
- let glyph = pixglyph::Glyph::load(font.ttf(), id)?;
- Some(Arc::new(glyph.rasterize(
- f32::from_bits(x),
- f32::from_bits(y),
- f32::from_bits(size),
- )))
- }
-
- // Try to retrieve a prepared glyph or prepare it from scratch if it
- // doesn't exist, yet.
- let bitmap =
- rasterize(&text.font, id, ts.tx.to_bits(), ts.ty.to_bits(), ppem.to_bits())?;
- match &text.fill {
- Paint::Gradient(gradient) => {
- let sampler = GradientSampler::new(gradient, &state, Size::zero(), true);
- write_bitmap(canvas, &bitmap, &state, sampler)?;
- }
- Paint::Solid(color) => {
- write_bitmap(canvas, &bitmap, &state, to_sk_color_u8(*color).premultiply())?;
- }
- Paint::Pattern(pattern) => {
- let pixmap = render_pattern_frame(&state, pattern);
- let sampler = PatternSampler::new(pattern, &pixmap, &state, true);
- write_bitmap(canvas, &bitmap, &state, sampler)?;
- }
- }
-
- Some(())
-}
-
-fn write_bitmap<S: PaintSampler>(
- canvas: &mut sk::Pixmap,
- bitmap: &Bitmap,
- state: &State,
- sampler: S,
-) -> Option<()> {
- // If we have a clip mask we first render to a pixmap that we then blend
- // with our canvas
- if state.mask.is_some() {
- let mw = bitmap.width;
- let mh = bitmap.height;
-
- // Pad the pixmap with 1 pixel in each dimension so that we do
- // not get any problem with floating point errors along their border
- let mut pixmap = sk::Pixmap::new(mw + 2, mh + 2)?;
- for x in 0..mw {
- for y in 0..mh {
- let alpha = bitmap.coverage[(y * mw + x) as usize];
- let color = sampler.sample((x, y));
- pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] =
- sk::ColorU8::from_rgba(
- color.red(),
- color.green(),
- color.blue(),
- alpha,
- )
- .premultiply();
- }
- }
-
- let left = bitmap.left;
- let top = bitmap.top;
-
- canvas.draw_pixmap(
- left - 1,
- top - 1,
- pixmap.as_ref(),
- &sk::PixmapPaint::default(),
- sk::Transform::identity(),
- state.mask,
- );
- } else {
- let cw = canvas.width() as i32;
- let ch = canvas.height() as i32;
- let mw = bitmap.width as i32;
- let mh = bitmap.height as i32;
-
- // Determine the pixel bounding box that we actually need to draw.
- let left = bitmap.left;
- let right = left + mw;
- let top = bitmap.top;
- let bottom = top + mh;
-
- // Blend the glyph bitmap with the existing pixels on the canvas.
- let pixels = bytemuck::cast_slice_mut::<u8, u32>(canvas.data_mut());
- for x in left.clamp(0, cw)..right.clamp(0, cw) {
- for y in top.clamp(0, ch)..bottom.clamp(0, ch) {
- let ai = ((y - top) * mw + (x - left)) as usize;
- let cov = bitmap.coverage[ai];
- if cov == 0 {
- continue;
- }
-
- let color = sampler.sample((x as _, y as _));
- let color = bytemuck::cast(color);
- let pi = (y * cw + x) as usize;
- // Fast path if color is opaque.
- if cov == u8::MAX && color & 0xFF == 0xFF {
- pixels[pi] = color;
- continue;
- }
-
- let applied = alpha_mul(color, cov as u32);
- pixels[pi] = blend_src_over(applied, pixels[pi]);
- }
- }
- }
-
- Some(())
-}
-
-/// Render a geometrical shape into the canvas.
-fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option<()> {
- let ts = state.transform;
- let path = match shape.geometry {
- Geometry::Line(target) => {
- let mut builder = sk::PathBuilder::new();
- builder.line_to(target.x.to_f32(), target.y.to_f32());
- builder.finish()?
- }
- Geometry::Rect(size) => {
- let w = size.x.to_f32();
- let h = size.y.to_f32();
- let rect = if w < 0.0 || h < 0.0 {
- // Skia doesn't normally allow for negative dimensions, but
- // Typst supports them, so we apply a transform if needed
- // Because this operation is expensive according to tiny-skia's
- // docs, we prefer to not apply it if not needed
- let transform = sk::Transform::from_scale(w.signum(), h.signum());
- let rect = sk::Rect::from_xywh(0.0, 0.0, w.abs(), h.abs())?;
- rect.transform(transform)?
- } else {
- sk::Rect::from_xywh(0.0, 0.0, w, h)?
- };
-
- sk::PathBuilder::from_rect(rect)
- }
- Geometry::Path(ref path) => convert_path(path)?,
- };
-
- if let Some(fill) = &shape.fill {
- let mut pixmap = None;
- let mut paint: sk::Paint = to_sk_paint(
- fill,
- state,
- shape.geometry.bbox_size(),
- false,
- None,
- &mut pixmap,
- None,
- );
-
- if matches!(shape.geometry, Geometry::Rect(_)) {
- paint.anti_alias = false;
- }
-
- let rule = sk::FillRule::default();
- canvas.fill_path(&path, &paint, rule, ts, state.mask);
- }
-
- if let Some(FixedStroke { paint, thickness, cap, join, dash, miter_limit }) =
- &shape.stroke
- {
- let width = thickness.to_f32();
-
- // Don't draw zero-pt stroke.
- if width > 0.0 {
- let dash = dash.as_ref().and_then(to_sk_dash_pattern);
-
- let bbox = shape.geometry.bbox_size();
- let offset_bbox = (!matches!(shape.geometry, Geometry::Line(..)))
- .then(|| offset_bounding_box(bbox, *thickness))
- .unwrap_or(bbox);
-
- let fill_transform =
- (!matches!(shape.geometry, Geometry::Line(..))).then(|| {
- sk::Transform::from_translate(
- -thickness.to_f32(),
- -thickness.to_f32(),
- )
- });
-
- let gradient_map =
- (!matches!(shape.geometry, Geometry::Line(..))).then(|| {
- (
- Point::new(
- -*thickness * state.pixel_per_pt as f64,
- -*thickness * state.pixel_per_pt as f64,
- ),
- Axes::new(
- Ratio::new(offset_bbox.x / bbox.x),
- Ratio::new(offset_bbox.y / bbox.y),
- ),
- )
- });
-
- let mut pixmap = None;
- let paint = to_sk_paint(
- paint,
- state,
- offset_bbox,
- false,
- fill_transform,
- &mut pixmap,
- gradient_map,
- );
- let stroke = sk::Stroke {
- width,
- line_cap: to_sk_line_cap(*cap),
- line_join: to_sk_line_join(*join),
- dash,
- miter_limit: miter_limit.get() as f32,
- };
- canvas.stroke_path(&path, &paint, &stroke, ts, state.mask);
- }
- }
-
- Some(())
-}
-
-/// Convert a Typst path into a tiny-skia path.
-fn convert_path(path: &Path) -> Option<sk::Path> {
- let mut builder = sk::PathBuilder::new();
- for elem in &path.0 {
- match elem {
- PathItem::MoveTo(p) => {
- builder.move_to(p.x.to_f32(), p.y.to_f32());
- }
- PathItem::LineTo(p) => {
- builder.line_to(p.x.to_f32(), p.y.to_f32());
- }
- PathItem::CubicTo(p1, p2, p3) => {
- builder.cubic_to(
- p1.x.to_f32(),
- p1.y.to_f32(),
- p2.x.to_f32(),
- p2.y.to_f32(),
- p3.x.to_f32(),
- p3.y.to_f32(),
- );
- }
- PathItem::ClosePath => {
- builder.close();
- }
- };
- }
- builder.finish()
-}
-
-/// Render a raster or SVG image into the canvas.
-fn render_image(
- canvas: &mut sk::Pixmap,
- state: State,
- image: &Image,
- size: Size,
-) -> Option<()> {
- let ts = state.transform;
- let view_width = size.x.to_f32();
- let view_height = size.y.to_f32();
-
- // For better-looking output, resize `image` to its final size before
- // painting it to `canvas`. For the math, see:
- // https://github.com/typst/typst/issues/1404#issuecomment-1598374652
- let theta = f32::atan2(-ts.kx, ts.sx);
-
- // To avoid division by 0, choose the one of { sin, cos } that is
- // further from 0.
- let prefer_sin = theta.sin().abs() > std::f32::consts::FRAC_1_SQRT_2;
- let scale_x =
- f32::abs(if prefer_sin { ts.kx / theta.sin() } else { ts.sx / theta.cos() });
-
- let aspect = (image.width() as f32) / (image.height() as f32);
- let w = (scale_x * view_width.max(aspect * view_height)).ceil() as u32;
- let h = ((w as f32) / aspect).ceil() as u32;
-
- let pixmap = scaled_texture(image, w, h)?;
- let paint_scale_x = view_width / pixmap.width() as f32;
- let paint_scale_y = view_height / pixmap.height() as f32;
-
- let paint = sk::Paint {
- shader: sk::Pattern::new(
- (*pixmap).as_ref(),
- sk::SpreadMode::Pad,
- sk::FilterQuality::Nearest,
- 1.0,
- sk::Transform::from_scale(paint_scale_x, paint_scale_y),
- ),
- ..Default::default()
- };
-
- let rect = sk::Rect::from_xywh(0.0, 0.0, view_width, view_height)?;
- canvas.fill_rect(rect, &paint, ts, state.mask);
-
- Some(())
-}
-
-/// Prepare a texture for an image at a scaled size.
-#[comemo::memoize]
-fn scaled_texture(image: &Image, w: u32, h: u32) -> Option<Arc<sk::Pixmap>> {
- let mut pixmap = sk::Pixmap::new(w, h)?;
- match image.kind() {
- ImageKind::Raster(raster) => {
- let downscale = w < raster.width();
- let filter =
- if downscale { FilterType::Lanczos3 } else { FilterType::CatmullRom };
- let buf = raster.dynamic().resize(w, h, filter);
- for ((_, _, src), dest) in buf.pixels().zip(pixmap.pixels_mut()) {
- let Rgba([r, g, b, a]) = src;
- *dest = sk::ColorU8::from_rgba(r, g, b, a).premultiply();
- }
- }
- // Safety: We do not keep any references to tree nodes beyond the scope
- // of `with`.
- ImageKind::Svg(svg) => unsafe {
- svg.with(|tree| {
- let ts = tiny_skia::Transform::from_scale(
- w as f32 / tree.size.width(),
- h as f32 / tree.size.height(),
- );
- resvg::render(tree, ts, &mut pixmap.as_mut())
- });
- },
- }
- Some(Arc::new(pixmap))
-}
-
-/// Trait for sampling of a paint, used as a generic
-/// abstraction over solid colors and gradients.
-trait PaintSampler: Copy {
- /// Sample the color at the `pos` in the pixmap.
- fn sample(self, pos: (u32, u32)) -> sk::PremultipliedColorU8;
-}
-
-impl PaintSampler for sk::PremultipliedColorU8 {
- fn sample(self, _: (u32, u32)) -> sk::PremultipliedColorU8 {
- self
- }
-}
-
-/// State used when sampling colors for text.
-///
-/// It caches the inverse transform to the parent, so that we can
-/// reuse it instead of recomputing it for each pixel.
-#[derive(Clone, Copy)]
-struct GradientSampler<'a> {
- gradient: &'a Gradient,
- container_size: Size,
- transform_to_parent: sk::Transform,
-}
-
-impl<'a> GradientSampler<'a> {
- fn new(
- gradient: &'a Gradient,
- state: &State,
- item_size: Size,
- on_text: bool,
- ) -> Self {
- let relative = gradient.unwrap_relative(on_text);
- let container_size = match relative {
- RelativeTo::Self_ => item_size,
- RelativeTo::Parent => state.size,
- };
-
- let fill_transform = match relative {
- RelativeTo::Self_ => sk::Transform::identity(),
- RelativeTo::Parent => state.container_transform.invert().unwrap(),
- };
-
- Self {
- gradient,
- container_size,
- transform_to_parent: fill_transform,
- }
- }
-}
-
-impl PaintSampler for GradientSampler<'_> {
- /// Samples a single point in a glyph.
- fn sample(self, (x, y): (u32, u32)) -> sk::PremultipliedColorU8 {
- // Compute the point in the gradient's coordinate space.
- let mut point = sk::Point { x: x as f32, y: y as f32 };
- self.transform_to_parent.map_point(&mut point);
-
- // Sample the gradient
- to_sk_color_u8(self.gradient.sample_at(
- (point.x, point.y),
- (self.container_size.x.to_f32(), self.container_size.y.to_f32()),
- ))
- .premultiply()
- }
-}
-
-/// State used when sampling patterns for text.
-///
-/// It caches the inverse transform to the parent, so that we can
-/// reuse it instead of recomputing it for each pixel.
-#[derive(Clone, Copy)]
-struct PatternSampler<'a> {
- size: Size,
- transform_to_parent: sk::Transform,
- pixmap: &'a sk::Pixmap,
- pixel_per_pt: f32,
-}
-
-impl<'a> PatternSampler<'a> {
- fn new(
- pattern: &'a Pattern,
- pixmap: &'a sk::Pixmap,
- state: &State,
- on_text: bool,
- ) -> Self {
- let relative = pattern.unwrap_relative(on_text);
- let fill_transform = match relative {
- RelativeTo::Self_ => sk::Transform::identity(),
- RelativeTo::Parent => state.container_transform.invert().unwrap(),
- };
-
- Self {
- pixmap,
- size: (pattern.size() + pattern.spacing()) * state.pixel_per_pt as f64,
- transform_to_parent: fill_transform,
- pixel_per_pt: state.pixel_per_pt,
- }
- }
-}
-
-impl PaintSampler for PatternSampler<'_> {
- /// Samples a single point in a glyph.
- fn sample(self, (x, y): (u32, u32)) -> sk::PremultipliedColorU8 {
- // Compute the point in the pattern's coordinate space.
- let mut point = sk::Point { x: x as f32, y: y as f32 };
- self.transform_to_parent.map_point(&mut point);
-
- let x =
- (point.x * self.pixel_per_pt).rem_euclid(self.size.x.to_f32()).floor() as u32;
- let y =
- (point.y * self.pixel_per_pt).rem_euclid(self.size.y.to_f32()).floor() as u32;
-
- // Sample the pattern
- self.pixmap.pixel(x, y).unwrap()
- }
-}
-
-/// Transforms a [`Paint`] into a [`sk::Paint`].
-/// Applying the necessary transform, if the paint is a gradient.
-///
-/// `gradient_map` is used to scale and move the gradient being sampled,
-/// this is used to line up the stroke and the fill of a shape.
-fn to_sk_paint<'a>(
- paint: &Paint,
- state: State,
- item_size: Size,
- on_text: bool,
- fill_transform: Option<sk::Transform>,
- pixmap: &'a mut Option<Arc<sk::Pixmap>>,
- gradient_map: Option<(Point, Axes<Ratio>)>,
-) -> sk::Paint<'a> {
- /// Actual sampling of the gradient, cached for performance.
- #[comemo::memoize]
- fn cached(
- gradient: &Gradient,
- width: u32,
- height: u32,
- gradient_map: Option<(Point, Axes<Ratio>)>,
- ) -> Arc<sk::Pixmap> {
- let (offset, scale) =
- gradient_map.unwrap_or_else(|| (Point::zero(), Axes::splat(Ratio::one())));
- let mut pixmap = sk::Pixmap::new(width.max(1), height.max(1)).unwrap();
- for x in 0..width {
- for y in 0..height {
- let color = gradient.sample_at(
- (
- (x as f32 + offset.x.to_f32()) * scale.x.get() as f32,
- (y as f32 + offset.y.to_f32()) * scale.y.get() as f32,
- ),
- (width as f32, height as f32),
- );
-
- pixmap.pixels_mut()[(y * width + x) as usize] =
- to_sk_color(color).premultiply().to_color_u8();
- }
- }
-
- Arc::new(pixmap)
- }
-
- let mut sk_paint: sk::Paint<'_> = sk::Paint::default();
- match paint {
- Paint::Solid(color) => {
- sk_paint.set_color(to_sk_color(*color));
- sk_paint.anti_alias = true;
- }
- Paint::Gradient(gradient) => {
- let relative = gradient.unwrap_relative(on_text);
- let container_size = match relative {
- RelativeTo::Self_ => item_size,
- RelativeTo::Parent => state.size,
- };
-
- let fill_transform = match relative {
- RelativeTo::Self_ => fill_transform.unwrap_or_default(),
- RelativeTo::Parent => state
- .container_transform
- .post_concat(state.transform.invert().unwrap()),
- };
- let width =
- (container_size.x.to_f32().abs() * state.pixel_per_pt).ceil() as u32;
- let height =
- (container_size.y.to_f32().abs() * state.pixel_per_pt).ceil() as u32;
-
- *pixmap = Some(cached(
- gradient,
- width.max(state.pixel_per_pt.ceil() as u32),
- height.max(state.pixel_per_pt.ceil() as u32),
- gradient_map,
- ));
-
- // We can use FilterQuality::Nearest here because we're
- // rendering to a pixmap that is already at native resolution.
- sk_paint.shader = sk::Pattern::new(
- pixmap.as_ref().unwrap().as_ref().as_ref(),
- sk::SpreadMode::Pad,
- sk::FilterQuality::Nearest,
- 1.0,
- fill_transform.pre_scale(
- container_size.x.signum() as f32 / state.pixel_per_pt,
- container_size.y.signum() as f32 / state.pixel_per_pt,
- ),
- );
-
- sk_paint.anti_alias = gradient.anti_alias();
- }
- Paint::Pattern(pattern) => {
- let relative = pattern.unwrap_relative(on_text);
-
- let fill_transform = match relative {
- RelativeTo::Self_ => fill_transform.unwrap_or_default(),
- RelativeTo::Parent => state
- .container_transform
- .post_concat(state.transform.invert().unwrap()),
- };
-
- let canvas = render_pattern_frame(&state, pattern);
- *pixmap = Some(Arc::new(canvas));
-
- // Create the shader
- sk_paint.shader = sk::Pattern::new(
- pixmap.as_ref().unwrap().as_ref().as_ref(),
- sk::SpreadMode::Repeat,
- sk::FilterQuality::Nearest,
- 1.0,
- fill_transform
- .pre_scale(1.0 / state.pixel_per_pt, 1.0 / state.pixel_per_pt),
- );
- }
- }
-
- sk_paint
-}
-
-fn render_pattern_frame(state: &State, pattern: &Pattern) -> sk::Pixmap {
- let size = pattern.size() + pattern.spacing();
- let mut canvas = sk::Pixmap::new(
- (size.x.to_f32() * state.pixel_per_pt).round() as u32,
- (size.y.to_f32() * state.pixel_per_pt).round() as u32,
- )
- .unwrap();
-
- // Render the pattern into a new canvas.
- let ts = sk::Transform::from_scale(state.pixel_per_pt, state.pixel_per_pt);
- let temp_state = State::new(pattern.size(), ts, state.pixel_per_pt);
- render_frame(&mut canvas, temp_state, pattern.frame());
- canvas
-}
-
-fn to_sk_color(color: Color) -> sk::Color {
- let [r, g, b, a] = color.to_rgb().to_vec4();
- sk::Color::from_rgba(r, g, b, a)
- .expect("components must always be in the range [0..=1]")
-}
-
-fn to_sk_color_u8(color: Color) -> sk::ColorU8 {
- let [r, g, b, a] = color.to_rgb().to_vec4_u8();
- sk::ColorU8::from_rgba(r, g, b, a)
-}
-
-fn to_sk_line_cap(cap: LineCap) -> sk::LineCap {
- match cap {
- LineCap::Butt => sk::LineCap::Butt,
- LineCap::Round => sk::LineCap::Round,
- LineCap::Square => sk::LineCap::Square,
- }
-}
-
-fn to_sk_line_join(join: LineJoin) -> sk::LineJoin {
- match join {
- LineJoin::Miter => sk::LineJoin::Miter,
- LineJoin::Round => sk::LineJoin::Round,
- LineJoin::Bevel => sk::LineJoin::Bevel,
- }
-}
-
fn to_sk_transform(transform: &Transform) -> sk::Transform {
let Transform { sx, ky, kx, sy, tx, ty } = *transform;
sk::Transform::from_row(
@@ -960,40 +241,6 @@ fn to_sk_transform(transform: &Transform) -> sk::Transform {
)
}
-fn to_sk_dash_pattern(pattern: &DashPattern<Abs, Abs>) -> Option<sk::StrokeDash> {
- // tiny-skia only allows dash patterns with an even number of elements,
- // while pdf allows any number.
- let pattern_len = pattern.array.len();
- let len = if pattern_len % 2 == 1 { 2 * pattern_len } else { pattern_len };
- let dash_array = pattern.array.iter().map(|l| l.to_f32()).cycle().take(len).collect();
- sk::StrokeDash::new(dash_array, pattern.phase.to_f32())
-}
-
-/// Allows to build tiny-skia paths from glyph outlines.
-struct WrappedPathBuilder(sk::PathBuilder);
-
-impl OutlineBuilder for WrappedPathBuilder {
- fn move_to(&mut self, x: f32, y: f32) {
- self.0.move_to(x, y);
- }
-
- fn line_to(&mut self, x: f32, y: f32) {
- self.0.line_to(x, y);
- }
-
- fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
- self.0.quad_to(x1, y1, x, y);
- }
-
- fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
- self.0.cubic_to(x1, y1, x2, y2, x, y);
- }
-
- fn close(&mut self) {
- self.0.close();
- }
-}
-
/// Additional methods for [`Abs`].
trait AbsExt {
/// Convert to a number of points as f32.
@@ -1005,24 +252,3 @@ impl AbsExt for Abs {
self.to_pt() as f32
}
}
-
-// Alpha multiplication and blending are ported from:
-// https://skia.googlesource.com/skia/+/refs/heads/main/include/core/SkColorPriv.h
-
-/// Blends two premulitplied, packed 32-bit RGBA colors. Alpha channel must be
-/// in the 8 high bits.
-fn blend_src_over(src: u32, dst: u32) -> u32 {
- src + alpha_mul(dst, 256 - (src >> 24))
-}
-
-/// Alpha multiply a color.
-fn alpha_mul(color: u32, scale: u32) -> u32 {
- let mask = 0xff00ff;
- let rb = ((color & mask) * scale) >> 8;
- let ag = ((color >> 8) & mask) * scale;
- (rb & mask) | (ag & !mask)
-}
-
-fn offset_bounding_box(bbox: Size, stroke_width: Abs) -> Size {
- Size::new(bbox.x + stroke_width * 2.0, bbox.y + stroke_width * 2.0)
-}
diff --git a/crates/typst-render/src/paint.rs b/crates/typst-render/src/paint.rs
new file mode 100644
index 00000000..2b5c19c9
--- /dev/null
+++ b/crates/typst-render/src/paint.rs
@@ -0,0 +1,267 @@
+use std::sync::Arc;
+
+use tiny_skia as sk;
+use typst::layout::{Axes, Point, Ratio, Size};
+use typst::visualize::{Color, Gradient, Paint, Pattern, RelativeTo};
+
+use crate::{AbsExt, State};
+
+/// Trait for sampling of a paint, used as a generic
+/// abstraction over solid colors and gradients.
+pub trait PaintSampler: Copy {
+ /// Sample the color at the `pos` in the pixmap.
+ fn sample(self, pos: (u32, u32)) -> sk::PremultipliedColorU8;
+}
+
+impl PaintSampler for sk::PremultipliedColorU8 {
+ fn sample(self, _: (u32, u32)) -> sk::PremultipliedColorU8 {
+ self
+ }
+}
+
+/// State used when sampling colors for text.
+///
+/// It caches the inverse transform to the parent, so that we can
+/// reuse it instead of recomputing it for each pixel.
+#[derive(Clone, Copy)]
+pub struct GradientSampler<'a> {
+ gradient: &'a Gradient,
+ container_size: Size,
+ transform_to_parent: sk::Transform,
+}
+
+impl<'a> GradientSampler<'a> {
+ pub fn new(
+ gradient: &'a Gradient,
+ state: &State,
+ item_size: Size,
+ on_text: bool,
+ ) -> Self {
+ let relative = gradient.unwrap_relative(on_text);
+ let container_size = match relative {
+ RelativeTo::Self_ => item_size,
+ RelativeTo::Parent => state.size,
+ };
+
+ let fill_transform = match relative {
+ RelativeTo::Self_ => sk::Transform::identity(),
+ RelativeTo::Parent => state.container_transform.invert().unwrap(),
+ };
+
+ Self {
+ gradient,
+ container_size,
+ transform_to_parent: fill_transform,
+ }
+ }
+}
+
+impl PaintSampler for GradientSampler<'_> {
+ /// Samples a single point in a glyph.
+ fn sample(self, (x, y): (u32, u32)) -> sk::PremultipliedColorU8 {
+ // Compute the point in the gradient's coordinate space.
+ let mut point = sk::Point { x: x as f32, y: y as f32 };
+ self.transform_to_parent.map_point(&mut point);
+
+ // Sample the gradient
+ to_sk_color_u8(self.gradient.sample_at(
+ (point.x, point.y),
+ (self.container_size.x.to_f32(), self.container_size.y.to_f32()),
+ ))
+ .premultiply()
+ }
+}
+
+/// State used when sampling patterns for text.
+///
+/// It caches the inverse transform to the parent, so that we can
+/// reuse it instead of recomputing it for each pixel.
+#[derive(Clone, Copy)]
+pub struct PatternSampler<'a> {
+ size: Size,
+ transform_to_parent: sk::Transform,
+ pixmap: &'a sk::Pixmap,
+ pixel_per_pt: f32,
+}
+
+impl<'a> PatternSampler<'a> {
+ pub fn new(
+ pattern: &'a Pattern,
+ pixmap: &'a sk::Pixmap,
+ state: &State,
+ on_text: bool,
+ ) -> Self {
+ let relative = pattern.unwrap_relative(on_text);
+ let fill_transform = match relative {
+ RelativeTo::Self_ => sk::Transform::identity(),
+ RelativeTo::Parent => state.container_transform.invert().unwrap(),
+ };
+
+ Self {
+ pixmap,
+ size: (pattern.size() + pattern.spacing()) * state.pixel_per_pt as f64,
+ transform_to_parent: fill_transform,
+ pixel_per_pt: state.pixel_per_pt,
+ }
+ }
+}
+
+impl PaintSampler for PatternSampler<'_> {
+ /// Samples a single point in a glyph.
+ fn sample(self, (x, y): (u32, u32)) -> sk::PremultipliedColorU8 {
+ // Compute the point in the pattern's coordinate space.
+ let mut point = sk::Point { x: x as f32, y: y as f32 };
+ self.transform_to_parent.map_point(&mut point);
+
+ let x =
+ (point.x * self.pixel_per_pt).rem_euclid(self.size.x.to_f32()).floor() as u32;
+ let y =
+ (point.y * self.pixel_per_pt).rem_euclid(self.size.y.to_f32()).floor() as u32;
+
+ // Sample the pattern
+ self.pixmap.pixel(x, y).unwrap()
+ }
+}
+
+/// Transforms a [`Paint`] into a [`sk::Paint`].
+/// Applying the necessary transform, if the paint is a gradient.
+///
+/// `gradient_map` is used to scale and move the gradient being sampled,
+/// this is used to line up the stroke and the fill of a shape.
+pub fn to_sk_paint<'a>(
+ paint: &Paint,
+ state: State,
+ item_size: Size,
+ on_text: bool,
+ fill_transform: Option<sk::Transform>,
+ pixmap: &'a mut Option<Arc<sk::Pixmap>>,
+ gradient_map: Option<(Point, Axes<Ratio>)>,
+) -> sk::Paint<'a> {
+ /// Actual sampling of the gradient, cached for performance.
+ #[comemo::memoize]
+ fn cached(
+ gradient: &Gradient,
+ width: u32,
+ height: u32,
+ gradient_map: Option<(Point, Axes<Ratio>)>,
+ ) -> Arc<sk::Pixmap> {
+ let (offset, scale) =
+ gradient_map.unwrap_or_else(|| (Point::zero(), Axes::splat(Ratio::one())));
+ let mut pixmap = sk::Pixmap::new(width.max(1), height.max(1)).unwrap();
+ for x in 0..width {
+ for y in 0..height {
+ let color = gradient.sample_at(
+ (
+ (x as f32 + offset.x.to_f32()) * scale.x.get() as f32,
+ (y as f32 + offset.y.to_f32()) * scale.y.get() as f32,
+ ),
+ (width as f32, height as f32),
+ );
+
+ pixmap.pixels_mut()[(y * width + x) as usize] =
+ to_sk_color(color).premultiply().to_color_u8();
+ }
+ }
+
+ Arc::new(pixmap)
+ }
+
+ let mut sk_paint: sk::Paint<'_> = sk::Paint::default();
+ match paint {
+ Paint::Solid(color) => {
+ sk_paint.set_color(to_sk_color(*color));
+ sk_paint.anti_alias = true;
+ }
+ Paint::Gradient(gradient) => {
+ let relative = gradient.unwrap_relative(on_text);
+ let container_size = match relative {
+ RelativeTo::Self_ => item_size,
+ RelativeTo::Parent => state.size,
+ };
+
+ let fill_transform = match relative {
+ RelativeTo::Self_ => fill_transform.unwrap_or_default(),
+ RelativeTo::Parent => state
+ .container_transform
+ .post_concat(state.transform.invert().unwrap()),
+ };
+ let width =
+ (container_size.x.to_f32().abs() * state.pixel_per_pt).ceil() as u32;
+ let height =
+ (container_size.y.to_f32().abs() * state.pixel_per_pt).ceil() as u32;
+
+ *pixmap = Some(cached(
+ gradient,
+ width.max(state.pixel_per_pt.ceil() as u32),
+ height.max(state.pixel_per_pt.ceil() as u32),
+ gradient_map,
+ ));
+
+ // We can use FilterQuality::Nearest here because we're
+ // rendering to a pixmap that is already at native resolution.
+ sk_paint.shader = sk::Pattern::new(
+ pixmap.as_ref().unwrap().as_ref().as_ref(),
+ sk::SpreadMode::Pad,
+ sk::FilterQuality::Nearest,
+ 1.0,
+ fill_transform.pre_scale(
+ container_size.x.signum() as f32 / state.pixel_per_pt,
+ container_size.y.signum() as f32 / state.pixel_per_pt,
+ ),
+ );
+
+ sk_paint.anti_alias = gradient.anti_alias();
+ }
+ Paint::Pattern(pattern) => {
+ let relative = pattern.unwrap_relative(on_text);
+
+ let fill_transform = match relative {
+ RelativeTo::Self_ => fill_transform.unwrap_or_default(),
+ RelativeTo::Parent => state
+ .container_transform
+ .post_concat(state.transform.invert().unwrap()),
+ };
+
+ let canvas = render_pattern_frame(&state, pattern);
+ *pixmap = Some(Arc::new(canvas));
+
+ // Create the shader
+ sk_paint.shader = sk::Pattern::new(
+ pixmap.as_ref().unwrap().as_ref().as_ref(),
+ sk::SpreadMode::Repeat,
+ sk::FilterQuality::Nearest,
+ 1.0,
+ fill_transform
+ .pre_scale(1.0 / state.pixel_per_pt, 1.0 / state.pixel_per_pt),
+ );
+ }
+ }
+
+ sk_paint
+}
+
+pub fn to_sk_color(color: Color) -> sk::Color {
+ let [r, g, b, a] = color.to_rgb().to_vec4();
+ sk::Color::from_rgba(r, g, b, a)
+ .expect("components must always be in the range [0..=1]")
+}
+
+pub fn to_sk_color_u8(color: Color) -> sk::ColorU8 {
+ let [r, g, b, a] = color.to_rgb().to_vec4_u8();
+ sk::ColorU8::from_rgba(r, g, b, a)
+}
+
+pub fn render_pattern_frame(state: &State, pattern: &Pattern) -> sk::Pixmap {
+ let size = pattern.size() + pattern.spacing();
+ let mut canvas = sk::Pixmap::new(
+ (size.x.to_f32() * state.pixel_per_pt).round() as u32,
+ (size.y.to_f32() * state.pixel_per_pt).round() as u32,
+ )
+ .unwrap();
+
+ // Render the pattern into a new canvas.
+ let ts = sk::Transform::from_scale(state.pixel_per_pt, state.pixel_per_pt);
+ let temp_state = State::new(pattern.size(), ts, state.pixel_per_pt);
+ crate::render_frame(&mut canvas, temp_state, pattern.frame());
+ canvas
+}
diff --git a/crates/typst-render/src/shape.rs b/crates/typst-render/src/shape.rs
new file mode 100644
index 00000000..360c2a4f
--- /dev/null
+++ b/crates/typst-render/src/shape.rs
@@ -0,0 +1,174 @@
+use tiny_skia as sk;
+use typst::layout::{Abs, Axes, Point, Ratio, Size};
+use typst::visualize::{
+ DashPattern, FixedStroke, Geometry, LineCap, LineJoin, Path, PathItem, Shape,
+};
+
+use crate::{paint, AbsExt, State};
+
+/// Render a geometrical shape into the canvas.
+pub fn render_shape(canvas: &mut sk::Pixmap, state: State, shape: &Shape) -> Option<()> {
+ let ts = state.transform;
+ let path = match shape.geometry {
+ Geometry::Line(target) => {
+ let mut builder = sk::PathBuilder::new();
+ builder.line_to(target.x.to_f32(), target.y.to_f32());
+ builder.finish()?
+ }
+ Geometry::Rect(size) => {
+ let w = size.x.to_f32();
+ let h = size.y.to_f32();
+ let rect = if w < 0.0 || h < 0.0 {
+ // Skia doesn't normally allow for negative dimensions, but
+ // Typst supports them, so we apply a transform if needed
+ // Because this operation is expensive according to tiny-skia's
+ // docs, we prefer to not apply it if not needed
+ let transform = sk::Transform::from_scale(w.signum(), h.signum());
+ let rect = sk::Rect::from_xywh(0.0, 0.0, w.abs(), h.abs())?;
+ rect.transform(transform)?
+ } else {
+ sk::Rect::from_xywh(0.0, 0.0, w, h)?
+ };
+
+ sk::PathBuilder::from_rect(rect)
+ }
+ Geometry::Path(ref path) => convert_path(path)?,
+ };
+
+ if let Some(fill) = &shape.fill {
+ let mut pixmap = None;
+ let mut paint: sk::Paint = paint::to_sk_paint(
+ fill,
+ state,
+ shape.geometry.bbox_size(),
+ false,
+ None,
+ &mut pixmap,
+ None,
+ );
+
+ if matches!(shape.geometry, Geometry::Rect(_)) {
+ paint.anti_alias = false;
+ }
+
+ let rule = sk::FillRule::default();
+ canvas.fill_path(&path, &paint, rule, ts, state.mask);
+ }
+
+ if let Some(FixedStroke { paint, thickness, cap, join, dash, miter_limit }) =
+ &shape.stroke
+ {
+ let width = thickness.to_f32();
+
+ // Don't draw zero-pt stroke.
+ if width > 0.0 {
+ let dash = dash.as_ref().and_then(to_sk_dash_pattern);
+
+ let bbox = shape.geometry.bbox_size();
+ let offset_bbox = (!matches!(shape.geometry, Geometry::Line(..)))
+ .then(|| offset_bounding_box(bbox, *thickness))
+ .unwrap_or(bbox);
+
+ let fill_transform =
+ (!matches!(shape.geometry, Geometry::Line(..))).then(|| {
+ sk::Transform::from_translate(
+ -thickness.to_f32(),
+ -thickness.to_f32(),
+ )
+ });
+
+ let gradient_map =
+ (!matches!(shape.geometry, Geometry::Line(..))).then(|| {
+ (
+ Point::new(
+ -*thickness * state.pixel_per_pt as f64,
+ -*thickness * state.pixel_per_pt as f64,
+ ),
+ Axes::new(
+ Ratio::new(offset_bbox.x / bbox.x),
+ Ratio::new(offset_bbox.y / bbox.y),
+ ),
+ )
+ });
+
+ let mut pixmap = None;
+ let paint = paint::to_sk_paint(
+ paint,
+ state,
+ offset_bbox,
+ false,
+ fill_transform,
+ &mut pixmap,
+ gradient_map,
+ );
+ let stroke = sk::Stroke {
+ width,
+ line_cap: to_sk_line_cap(*cap),
+ line_join: to_sk_line_join(*join),
+ dash,
+ miter_limit: miter_limit.get() as f32,
+ };
+ canvas.stroke_path(&path, &paint, &stroke, ts, state.mask);
+ }
+ }
+
+ Some(())
+}
+
+/// Convert a Typst path into a tiny-skia path.
+pub fn convert_path(path: &Path) -> Option<sk::Path> {
+ let mut builder = sk::PathBuilder::new();
+ for elem in &path.0 {
+ match elem {
+ PathItem::MoveTo(p) => {
+ builder.move_to(p.x.to_f32(), p.y.to_f32());
+ }
+ PathItem::LineTo(p) => {
+ builder.line_to(p.x.to_f32(), p.y.to_f32());
+ }
+ PathItem::CubicTo(p1, p2, p3) => {
+ builder.cubic_to(
+ p1.x.to_f32(),
+ p1.y.to_f32(),
+ p2.x.to_f32(),
+ p2.y.to_f32(),
+ p3.x.to_f32(),
+ p3.y.to_f32(),
+ );
+ }
+ PathItem::ClosePath => {
+ builder.close();
+ }
+ };
+ }
+ builder.finish()
+}
+
+fn offset_bounding_box(bbox: Size, stroke_width: Abs) -> Size {
+ Size::new(bbox.x + stroke_width * 2.0, bbox.y + stroke_width * 2.0)
+}
+
+pub fn to_sk_line_cap(cap: LineCap) -> sk::LineCap {
+ match cap {
+ LineCap::Butt => sk::LineCap::Butt,
+ LineCap::Round => sk::LineCap::Round,
+ LineCap::Square => sk::LineCap::Square,
+ }
+}
+
+pub fn to_sk_line_join(join: LineJoin) -> sk::LineJoin {
+ match join {
+ LineJoin::Miter => sk::LineJoin::Miter,
+ LineJoin::Round => sk::LineJoin::Round,
+ LineJoin::Bevel => sk::LineJoin::Bevel,
+ }
+}
+
+pub fn to_sk_dash_pattern(pattern: &DashPattern<Abs, Abs>) -> Option<sk::StrokeDash> {
+ // tiny-skia only allows dash patterns with an even number of elements,
+ // while pdf allows any number.
+ let pattern_len = pattern.array.len();
+ let len = if pattern_len % 2 == 1 { 2 * pattern_len } else { pattern_len };
+ let dash_array = pattern.array.iter().map(|l| l.to_f32()).cycle().take(len).collect();
+ sk::StrokeDash::new(dash_array, pattern.phase.to_f32())
+}
diff --git a/crates/typst-render/src/text.rs b/crates/typst-render/src/text.rs
new file mode 100644
index 00000000..c4e83340
--- /dev/null
+++ b/crates/typst-render/src/text.rs
@@ -0,0 +1,281 @@
+use std::sync::Arc;
+
+use pixglyph::Bitmap;
+use tiny_skia as sk;
+use ttf_parser::{GlyphId, OutlineBuilder};
+use typst::layout::{Abs, Axes, Point, Size};
+use typst::text::color::{frame_for_glyph, is_color_glyph};
+use typst::text::{Font, TextItem};
+use typst::visualize::{FixedStroke, Paint};
+
+use crate::paint::{self, GradientSampler, PaintSampler, PatternSampler};
+use crate::{shape, AbsExt, State};
+
+/// Render a text run into the canvas.
+pub fn render_text(canvas: &mut sk::Pixmap, state: State, text: &TextItem) {
+ let mut x = 0.0;
+ for glyph in &text.glyphs {
+ let id = GlyphId(glyph.id);
+ let offset = x + glyph.x_offset.at(text.size).to_f32();
+
+ if is_color_glyph(&text.font, glyph) {
+ let upem = text.font.units_per_em();
+ let text_scale = Abs::raw(text.size.to_raw() / upem);
+ let state = state
+ .pre_translate(Point::new(Abs::raw(offset as _), -text.size))
+ .pre_scale(Axes::new(text_scale, text_scale));
+
+ let glyph_frame = frame_for_glyph(&text.font, glyph.id);
+
+ crate::render_frame(canvas, state, &glyph_frame);
+ } else {
+ let state =
+ state.pre_translate(Point::new(Abs::raw(offset as _), Abs::raw(0.0)));
+ render_outline_glyph(canvas, state, text, id);
+ }
+
+ x += glyph.x_advance.at(text.size).to_f32();
+ }
+}
+
+/// Render an outline glyph into the canvas. This is the "normal" case.
+fn render_outline_glyph(
+ canvas: &mut sk::Pixmap,
+ state: State,
+ text: &TextItem,
+ id: GlyphId,
+) -> Option<()> {
+ let ts = &state.transform;
+ let ppem = text.size.to_f32() * ts.sy;
+
+ // Render a glyph directly as a path. This only happens when the fast glyph
+ // rasterization can't be used due to very large text size or weird
+ // scale/skewing transforms.
+ if ppem > 100.0
+ || ts.kx != 0.0
+ || ts.ky != 0.0
+ || ts.sx != ts.sy
+ || text.stroke.is_some()
+ {
+ let path = {
+ let mut builder = WrappedPathBuilder(sk::PathBuilder::new());
+ text.font.ttf().outline_glyph(id, &mut builder)?;
+ builder.0.finish()?
+ };
+
+ let scale = text.size.to_f32() / text.font.units_per_em() as f32;
+
+ let mut pixmap = None;
+
+ let rule = sk::FillRule::default();
+
+ // Flip vertically because font design coordinate
+ // system is Y-up.
+ let ts = ts.pre_scale(scale, -scale);
+ let state_ts = state.pre_concat(sk::Transform::from_scale(scale, -scale));
+ let paint = paint::to_sk_paint(
+ &text.fill,
+ state_ts,
+ Size::zero(),
+ true,
+ None,
+ &mut pixmap,
+ None,
+ );
+ canvas.fill_path(&path, &paint, rule, ts, state.mask);
+
+ if let Some(FixedStroke { paint, thickness, cap, join, dash, miter_limit }) =
+ &text.stroke
+ {
+ if thickness.to_f32() > 0.0 {
+ let dash = dash.as_ref().and_then(shape::to_sk_dash_pattern);
+
+ let paint = paint::to_sk_paint(
+ paint,
+ state_ts,
+ Size::zero(),
+ true,
+ None,
+ &mut pixmap,
+ None,
+ );
+ let stroke = sk::Stroke {
+ width: thickness.to_f32() / scale, // When we scale the path, we need to scale the stroke width, too.
+ line_cap: shape::to_sk_line_cap(*cap),
+ line_join: shape::to_sk_line_join(*join),
+ dash,
+ miter_limit: miter_limit.get() as f32,
+ };
+
+ canvas.stroke_path(&path, &paint, &stroke, ts, state.mask);
+ }
+ }
+ return Some(());
+ }
+
+ // Rasterize the glyph with `pixglyph`.
+ #[comemo::memoize]
+ fn rasterize(
+ font: &Font,
+ id: GlyphId,
+ x: u32,
+ y: u32,
+ size: u32,
+ ) -> Option<Arc<Bitmap>> {
+ let glyph = pixglyph::Glyph::load(font.ttf(), id)?;
+ Some(Arc::new(glyph.rasterize(
+ f32::from_bits(x),
+ f32::from_bits(y),
+ f32::from_bits(size),
+ )))
+ }
+
+ // Try to retrieve a prepared glyph or prepare it from scratch if it
+ // doesn't exist, yet.
+ let bitmap =
+ rasterize(&text.font, id, ts.tx.to_bits(), ts.ty.to_bits(), ppem.to_bits())?;
+ match &text.fill {
+ Paint::Gradient(gradient) => {
+ let sampler = GradientSampler::new(gradient, &state, Size::zero(), true);
+ write_bitmap(canvas, &bitmap, &state, sampler)?;
+ }
+ Paint::Solid(color) => {
+ write_bitmap(
+ canvas,
+ &bitmap,
+ &state,
+ paint::to_sk_color_u8(*color).premultiply(),
+ )?;
+ }
+ Paint::Pattern(pattern) => {
+ let pixmap = paint::render_pattern_frame(&state, pattern);
+ let sampler = PatternSampler::new(pattern, &pixmap, &state, true);
+ write_bitmap(canvas, &bitmap, &state, sampler)?;
+ }
+ }
+
+ Some(())
+}
+
+fn write_bitmap<S: PaintSampler>(
+ canvas: &mut sk::Pixmap,
+ bitmap: &Bitmap,
+ state: &State,
+ sampler: S,
+) -> Option<()> {
+ // If we have a clip mask we first render to a pixmap that we then blend
+ // with our canvas
+ if state.mask.is_some() {
+ let mw = bitmap.width;
+ let mh = bitmap.height;
+
+ // Pad the pixmap with 1 pixel in each dimension so that we do
+ // not get any problem with floating point errors along their border
+ let mut pixmap = sk::Pixmap::new(mw + 2, mh + 2)?;
+ for x in 0..mw {
+ for y in 0..mh {
+ let alpha = bitmap.coverage[(y * mw + x) as usize];
+ let color = sampler.sample((x, y));
+ pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] =
+ sk::ColorU8::from_rgba(
+ color.red(),
+ color.green(),
+ color.blue(),
+ alpha,
+ )
+ .premultiply();
+ }
+ }
+
+ let left = bitmap.left;
+ let top = bitmap.top;
+
+ canvas.draw_pixmap(
+ left - 1,
+ top - 1,
+ pixmap.as_ref(),
+ &sk::PixmapPaint::default(),
+ sk::Transform::identity(),
+ state.mask,
+ );
+ } else {
+ let cw = canvas.width() as i32;
+ let ch = canvas.height() as i32;
+ let mw = bitmap.width as i32;
+ let mh = bitmap.height as i32;
+
+ // Determine the pixel bounding box that we actually need to draw.
+ let left = bitmap.left;
+ let right = left + mw;
+ let top = bitmap.top;
+ let bottom = top + mh;
+
+ // Blend the glyph bitmap with the existing pixels on the canvas.
+ let pixels = bytemuck::cast_slice_mut::<u8, u32>(canvas.data_mut());
+ for x in left.clamp(0, cw)..right.clamp(0, cw) {
+ for y in top.clamp(0, ch)..bottom.clamp(0, ch) {
+ let ai = ((y - top) * mw + (x - left)) as usize;
+ let cov = bitmap.coverage[ai];
+ if cov == 0 {
+ continue;
+ }
+
+ let color = sampler.sample((x as _, y as _));
+ let color = bytemuck::cast(color);
+ let pi = (y * cw + x) as usize;
+ // Fast path if color is opaque.
+ if cov == u8::MAX && color & 0xFF == 0xFF {
+ pixels[pi] = color;
+ continue;
+ }
+
+ let applied = alpha_mul(color, cov as u32);
+ pixels[pi] = blend_src_over(applied, pixels[pi]);
+ }
+ }
+ }
+
+ Some(())
+}
+
+/// Allows to build tiny-skia paths from glyph outlines.
+struct WrappedPathBuilder(sk::PathBuilder);
+
+impl OutlineBuilder for WrappedPathBuilder {
+ fn move_to(&mut self, x: f32, y: f32) {
+ self.0.move_to(x, y);
+ }
+
+ fn line_to(&mut self, x: f32, y: f32) {
+ self.0.line_to(x, y);
+ }
+
+ fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
+ self.0.quad_to(x1, y1, x, y);
+ }
+
+ fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
+ self.0.cubic_to(x1, y1, x2, y2, x, y);
+ }
+
+ fn close(&mut self) {
+ self.0.close();
+ }
+}
+
+// Alpha multiplication and blending are ported from:
+// https://skia.googlesource.com/skia/+/refs/heads/main/include/core/SkColorPriv.h
+
+/// Blends two premulitplied, packed 32-bit RGBA colors. Alpha channel must be
+/// in the 8 high bits.
+fn blend_src_over(src: u32, dst: u32) -> u32 {
+ src + alpha_mul(dst, 256 - (src >> 24))
+}
+
+/// Alpha multiply a color.
+fn alpha_mul(color: u32, scale: u32) -> u32 {
+ let mask = 0xff00ff;
+ let rb = ((color & mask) * scale) >> 8;
+ let ag = ((color >> 8) & mask) * scale;
+ (rb & mask) | (ag & !mask)
+}