From 1756718bab3055597723a9b433419ff07e6b7f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20d=27Herbais=20de=20Thun?= Date: Fri, 24 Nov 2023 15:46:20 +0100 Subject: [PATCH] Gradient Part 6 - Pattern fills (#2740) --- crates/typst-pdf/src/color.rs | 2 + crates/typst-pdf/src/gradient.rs | 43 ++-- crates/typst-pdf/src/lib.rs | 34 ++- crates/typst-pdf/src/page.rs | 113 +++++++-- crates/typst-pdf/src/pattern.rs | 154 ++++++++++++ crates/typst-render/src/lib.rs | 151 ++++++++++-- crates/typst-svg/src/lib.rs | 296 ++++++++++++++++++----- crates/typst/src/eval/ops.rs | 9 + crates/typst/src/foundations/value.rs | 9 +- crates/typst/src/layout/axes.rs | 12 + crates/typst/src/text/mod.rs | 20 +- crates/typst/src/visualize/gradient.rs | 24 +- crates/typst/src/visualize/mod.rs | 3 + crates/typst/src/visualize/paint.rs | 34 ++- crates/typst/src/visualize/pattern.rs | 288 ++++++++++++++++++++++ crates/typst/src/visualize/stroke.rs | 6 +- tests/ref/visualize/pattern-relative.png | Bin 0 -> 1548 bytes tests/ref/visualize/pattern-small.png | Bin 0 -> 106 bytes tests/ref/visualize/pattern-spacing.png | Bin 0 -> 307 bytes tests/ref/visualize/pattern-stroke.png | Bin 0 -> 352 bytes tests/ref/visualize/pattern-text.png | Bin 0 -> 29319 bytes tests/typ/layout/table.typ | 2 +- tests/typ/visualize/gradient-text.typ | 2 +- tests/typ/visualize/pattern-relative.typ | 23 ++ tests/typ/visualize/pattern-small.typ | 14 ++ tests/typ/visualize/pattern-spacing.typ | 31 +++ tests/typ/visualize/pattern-stroke.typ | 13 + tests/typ/visualize/pattern-text.typ | 28 +++ tests/typ/visualize/shape-rect.typ | 2 +- 29 files changed, 1145 insertions(+), 168 deletions(-) create mode 100644 crates/typst-pdf/src/pattern.rs create mode 100644 crates/typst/src/visualize/pattern.rs create mode 100644 tests/ref/visualize/pattern-relative.png create mode 100644 tests/ref/visualize/pattern-small.png create mode 100644 tests/ref/visualize/pattern-spacing.png create mode 100644 tests/ref/visualize/pattern-stroke.png create mode 100644 tests/ref/visualize/pattern-text.png create mode 100644 tests/typ/visualize/pattern-relative.typ create mode 100644 tests/typ/visualize/pattern-small.typ create mode 100644 tests/typ/visualize/pattern-spacing.typ create mode 100644 tests/typ/visualize/pattern-stroke.typ create mode 100644 tests/typ/visualize/pattern-text.typ diff --git a/crates/typst-pdf/src/color.rs b/crates/typst-pdf/src/color.rs index 17c4686af..d7781b352 100644 --- a/crates/typst-pdf/src/color.rs +++ b/crates/typst-pdf/src/color.rs @@ -294,6 +294,7 @@ impl PaintEncode for Paint { match self { Self::Solid(c) => c.set_as_fill(ctx, on_text, transforms), Self::Gradient(gradient) => gradient.set_as_fill(ctx, on_text, transforms), + Self::Pattern(pattern) => pattern.set_as_fill(ctx, on_text, transforms), } } @@ -301,6 +302,7 @@ impl PaintEncode for Paint { match self { Self::Solid(c) => c.set_as_stroke(ctx, transforms), Self::Gradient(gradient) => gradient.set_as_stroke(ctx, transforms), + Self::Pattern(pattern) => pattern.set_as_stroke(ctx, transforms), } } } diff --git a/crates/typst-pdf/src/gradient.rs b/crates/typst-pdf/src/gradient.rs index 5e7e5f3d9..b12ac53fa 100644 --- a/crates/typst-pdf/src/gradient.rs +++ b/crates/typst-pdf/src/gradient.rs @@ -1,19 +1,19 @@ use std::f32::consts::{PI, TAU}; use std::sync::Arc; -use ecow::{eco_format, EcoString}; +use ecow::eco_format; use pdf_writer::types::{ColorSpaceOperand, FunctionShadingType}; use pdf_writer::writers::StreamShadingType; use pdf_writer::{Filter, Finish, Name, Ref}; use typst::layout::{Abs, Angle, Point, Quadrant, Ratio, Transform}; use typst::util::Numeric; use typst::visualize::{ - Color, ColorSpace, ConicGradient, Gradient, GradientRelative, WeightedColor, + Color, ColorSpace, ConicGradient, Gradient, RelativeTo, WeightedColor, }; use crate::color::{ColorSpaceExt, PaintEncode, QuantizedColor}; -use crate::page::{PageContext, Transforms}; -use crate::{deflate, AbsExt, PdfContext}; +use crate::page::{PageContext, PageResource, ResourceKind, Transforms}; +use crate::{deflate, transform_to_array, AbsExt, PdfContext}; /// A unique-transform-aspect-ratio combination that will be encoded into the /// PDF. @@ -268,21 +268,27 @@ impl PaintEncode for Gradient { fn set_as_fill(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms) { ctx.reset_fill_color_space(); - let id = register_gradient(ctx, self, on_text, transforms); + let index = register_gradient(ctx, self, on_text, transforms); + let id = eco_format!("Gr{index}"); let name = Name(id.as_bytes()); ctx.content.set_fill_color_space(ColorSpaceOperand::Pattern); ctx.content.set_fill_pattern(None, name); + ctx.resources + .insert(PageResource::new(ResourceKind::Gradient, id), index); } fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms) { ctx.reset_stroke_color_space(); - let id = register_gradient(ctx, self, false, transforms); + let index = register_gradient(ctx, self, false, transforms); + let id = eco_format!("Gr{index}"); let name = Name(id.as_bytes()); ctx.content.set_stroke_color_space(ColorSpaceOperand::Pattern); ctx.content.set_stroke_pattern(None, name); + ctx.resources + .insert(PageResource::new(ResourceKind::Gradient, id), index); } } @@ -292,7 +298,7 @@ fn register_gradient( gradient: &Gradient, on_text: bool, mut transforms: Transforms, -) -> EcoString { +) -> usize { // Edge cases for strokes. if transforms.size.x.is_zero() { transforms.size.x = Abs::pt(1.0); @@ -302,8 +308,8 @@ fn register_gradient( transforms.size.y = Abs::pt(1.0); } let size = match gradient.unwrap_relative(on_text) { - GradientRelative::Self_ => transforms.size, - GradientRelative::Parent => transforms.container_size, + RelativeTo::Self_ => transforms.size, + RelativeTo::Parent => transforms.container_size, }; let (offset_x, offset_y) = match gradient { @@ -317,8 +323,8 @@ fn register_gradient( let rotation = gradient.angle().unwrap_or_else(Angle::zero); let transform = match gradient.unwrap_relative(on_text) { - GradientRelative::Self_ => transforms.transform, - GradientRelative::Parent => transforms.container_transform, + RelativeTo::Self_ => transforms.transform, + RelativeTo::Parent => transforms.container_transform, }; let scale_offset = match gradient { @@ -341,20 +347,7 @@ fn register_gradient( angle: Gradient::correct_aspect_ratio(rotation, size.aspect_ratio()), }; - let index = ctx.parent.gradient_map.insert(pdf_gradient); - eco_format!("Gr{}", index) -} - -/// Convert to an array of floats. -fn transform_to_array(ts: Transform) -> [f32; 6] { - [ - ts.sx.get() as f32, - ts.ky.get() as f32, - ts.kx.get() as f32, - ts.sy.get() as f32, - ts.tx.to_f32(), - ts.ty.to_f32(), - ] + ctx.parent.gradient_map.insert(pdf_gradient) } /// Writes a single Coons Patch as defined in the PDF specification diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index c753315c6..005b5a9e0 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -7,10 +7,12 @@ mod gradient; mod image; mod outline; mod page; +mod pattern; use std::cmp::Eq; use std::collections::{BTreeMap, HashMap}; use std::hash::Hash; +use std::sync::Arc; use base64::Engine; use ecow::{eco_format, EcoString}; @@ -18,7 +20,7 @@ use pdf_writer::types::Direction; use pdf_writer::{Finish, Name, Pdf, Ref, TextStr}; use typst::foundations::Datetime; use typst::introspection::Introspector; -use typst::layout::{Abs, Dir, Em}; +use typst::layout::{Abs, Dir, Em, Transform}; use typst::model::Document; use typst::text::{Font, Lang}; use typst::util::Deferred; @@ -30,6 +32,7 @@ use crate::extg::ExtGState; use crate::gradient::PdfGradient; use crate::image::EncodedImage; use crate::page::Page; +use crate::pattern::PdfPattern; /// Export a document into a PDF file. /// @@ -57,6 +60,7 @@ pub fn pdf( image::write_images(&mut ctx); gradient::write_gradients(&mut ctx); extg::write_external_graphics_states(&mut ctx); + pattern::write_patterns(&mut ctx); page::write_page_tree(&mut ctx); write_catalog(&mut ctx, ident, timestamp); ctx.pdf.finish() @@ -97,6 +101,8 @@ struct PdfContext<'a> { image_refs: Vec, /// The IDs of written gradients. gradient_refs: Vec, + /// The IDs of written patterns. + pattern_refs: Vec, /// The IDs of written external graphics states. ext_gs_refs: Vec, /// Handles color space writing. @@ -110,6 +116,8 @@ struct PdfContext<'a> { image_deferred_map: HashMap>, /// Deduplicates gradients used across the document. gradient_map: Remapper, + /// Deduplicates patterns used across the document. + pattern_map: Remapper, /// Deduplicates external graphics states used across the document. extg_map: Remapper, } @@ -131,12 +139,14 @@ impl<'a> PdfContext<'a> { font_refs: vec![], image_refs: vec![], gradient_refs: vec![], + pattern_refs: vec![], ext_gs_refs: vec![], colors: ColorSpaces::default(), font_map: Remapper::new(), image_map: Remapper::new(), image_deferred_map: HashMap::default(), gradient_map: Remapper::new(), + pattern_map: Remapper::new(), extg_map: Remapper::new(), } } @@ -263,6 +273,12 @@ fn deflate(data: &[u8]) -> Vec { miniz_oxide::deflate::compress_to_vec_zlib(data, COMPRESSION_LEVEL) } +/// Memoized version of [`deflate`] specialized for a page's content stream. +#[comemo::memoize] +fn deflate_memoized(content: &[u8]) -> Arc> { + Arc::new(deflate(content)) +} + /// Create a base64-encoded hash of the value. fn hash_base64(value: &T) -> String { base64::engine::general_purpose::STANDARD @@ -341,10 +357,6 @@ where }) } - fn map(&self, item: &T) -> usize { - self.to_pdf[item] - } - fn pdf_indices<'a>( &'a self, refs: &'a [Ref], @@ -380,3 +392,15 @@ impl EmExt for Em { 1000.0 * self.get() as f32 } } + +/// Convert to an array of floats. +fn transform_to_array(ts: Transform) -> [f32; 6] { + [ + ts.sx.get() as f32, + ts.ky.get() as f32, + ts.kx.get() as f32, + ts.sy.get() as f32, + ts.tx.to_f32(), + ts.ty.to_f32(), + ] +} diff --git a/crates/typst-pdf/src/page.rs b/crates/typst-pdf/src/page.rs index 545380dac..1bbef7af6 100644 --- a/crates/typst-pdf/src/page.rs +++ b/crates/typst-pdf/src/page.rs @@ -1,7 +1,7 @@ +use std::collections::HashMap; use std::num::NonZeroUsize; -use std::sync::Arc; -use ecow::eco_format; +use ecow::{eco_format, EcoString}; use pdf_writer::types::{ ActionType, AnnotationType, ColorSpaceOperand, LineCapStyle, LineJoinStyle, NumberingStyle, @@ -23,21 +23,22 @@ use typst::visualize::{ use crate::color::PaintEncode; use crate::extg::ExtGState; use crate::image::deferred_image; -use crate::{deflate, AbsExt, EmExt, PdfContext}; +use crate::{deflate_memoized, AbsExt, EmExt, PdfContext}; /// Construct page objects. #[tracing::instrument(skip_all)] pub(crate) fn construct_pages(ctx: &mut PdfContext, frames: &[Frame]) { for frame in frames { - construct_page(ctx, frame); + let (page_ref, page) = construct_page(ctx, frame); + ctx.page_refs.push(page_ref); + ctx.pages.push(page); } } /// Construct a page object. #[tracing::instrument(skip_all)] -pub(crate) fn construct_page(ctx: &mut PdfContext, frame: &Frame) { +pub(crate) fn construct_page(ctx: &mut PdfContext, frame: &Frame) -> (Ref, Page) { let page_ref = ctx.alloc.bump(); - ctx.page_refs.push(page_ref); let mut ctx = PageContext { parent: ctx, @@ -49,6 +50,7 @@ pub(crate) fn construct_page(ctx: &mut PdfContext, frame: &Frame) { saves: vec![], bottom: 0.0, links: vec![], + resources: HashMap::default(), }; let size = frame.size(); @@ -74,9 +76,10 @@ pub(crate) fn construct_page(ctx: &mut PdfContext, frame: &Frame) { uses_opacities: ctx.uses_opacities, links: ctx.links, label: ctx.label, + resources: ctx.resources, }; - ctx.parent.pages.push(page); + (page_ref, page) } /// Write the page tree. @@ -117,6 +120,11 @@ pub(crate) fn write_page_tree(ctx: &mut PdfContext) { patterns.pair(Name(name.as_bytes()), gradient_ref); } + for (pattern_ref, p) in ctx.pattern_map.pdf_indices(&ctx.pattern_refs) { + let name = eco_format!("P{}", p); + patterns.pair(Name(name.as_bytes()), pattern_ref); + } + patterns.finish(); let mut ext_gs_states = resources.ext_g_states(); @@ -190,7 +198,7 @@ fn write_page(ctx: &mut PdfContext, i: usize) { annotations.finish(); page_writer.finish(); - let data = deflate_content(&page.content); + let data = deflate_memoized(&page.content); ctx.pdf.stream(content_id, &data).filter(Filter::FlateDecode); } @@ -243,12 +251,6 @@ pub(crate) fn write_page_labels(ctx: &mut PdfContext) -> Vec<(NonZeroUsize, Ref) result } -/// Memoized version of [`deflate`] specialized for a page's content stream. -#[comemo::memoize] -fn deflate_content(content: &[u8]) -> Arc> { - Arc::new(deflate(content)) -} - /// Data for an exported page. pub struct Page { /// The indirect object id of the page. @@ -263,6 +265,63 @@ pub struct Page { pub links: Vec<(Destination, Rect)>, /// The page's PDF label. pub label: Option, + /// The page's used resources + pub resources: HashMap, +} + +/// Represents a resource being used in a PDF page by its name. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PageResource { + kind: ResourceKind, + name: EcoString, +} + +impl PageResource { + pub fn new(kind: ResourceKind, name: EcoString) -> Self { + Self { kind, name } + } +} + +/// A kind of resource being used in a PDF page. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ResourceKind { + XObject, + Font, + Gradient, + Pattern, + ExtGState, +} + +impl PageResource { + /// Returns the name of the resource. + pub fn name(&self) -> Name<'_> { + Name(self.name.as_bytes()) + } + + /// Returns whether the resource is an XObject. + pub fn is_x_object(&self) -> bool { + matches!(self.kind, ResourceKind::XObject) + } + + /// Returns whether the resource is a font. + pub fn is_font(&self) -> bool { + matches!(self.kind, ResourceKind::Font) + } + + /// Returns whether the resource is a gradient. + pub fn is_gradient(&self) -> bool { + matches!(self.kind, ResourceKind::Gradient) + } + + /// Returns whether the resource is a pattern. + pub fn is_pattern(&self) -> bool { + matches!(self.kind, ResourceKind::Pattern) + } + + /// Returns whether the resource is an external graphics state. + pub fn is_ext_g_state(&self) -> bool { + matches!(self.kind, ResourceKind::ExtGState) + } } /// An exporter for the contents of a single PDF page. @@ -276,6 +335,8 @@ pub struct PageContext<'a, 'b> { bottom: f32, uses_opacities: bool, links: Vec<(Destination, Rect)>, + /// Keep track of the resources being used in the page. + pub resources: HashMap, } /// A simulated graphics state used to deduplicate graphics state changes and @@ -350,9 +411,11 @@ impl PageContext<'_, '_> { fn set_external_graphics_state(&mut self, graphics_state: &ExtGState) { let current_state = self.state.external_graphics_state.as_ref(); if current_state != Some(graphics_state) { - self.parent.extg_map.insert(*graphics_state); - let name = eco_format!("Gs{}", self.parent.extg_map.map(graphics_state)); + let index = self.parent.extg_map.insert(*graphics_state); + let name = eco_format!("Gs{index}"); self.content.set_parameters(Name(name.as_bytes())); + self.resources + .insert(PageResource::new(ResourceKind::ExtGState, name), index); if graphics_state.uses_opacities() { self.uses_opacities = true; @@ -365,7 +428,7 @@ impl PageContext<'_, '_> { .map(|stroke| { let color = match &stroke.paint { Paint::Solid(color) => *color, - Paint::Gradient(_) => return 255, + Paint::Gradient(_) | Paint::Pattern(_) => return 255, }; color.alpha().map_or(255, |v| (v * 255.0).round() as u8) @@ -375,7 +438,7 @@ impl PageContext<'_, '_> { .map(|paint| { let color = match paint { Paint::Solid(color) => *color, - Paint::Gradient(_) => return 255, + Paint::Gradient(_) | Paint::Pattern(_) => return 255, }; color.alpha().map_or(255, |v| (v * 255.0).round() as u8) @@ -407,9 +470,11 @@ impl PageContext<'_, '_> { fn set_font(&mut self, font: &Font, size: Abs) { if self.state.font.as_ref().map(|(f, s)| (f, *s)) != Some((font, size)) { - self.parent.font_map.insert(font.clone()); - let name = eco_format!("F{}", self.parent.font_map.map(font)); + let index = self.parent.font_map.insert(font.clone()); + let name = eco_format!("F{index}"); self.content.set_font(Name(name.as_bytes()), size.to_f32()); + self.resources + .insert(PageResource::new(ResourceKind::Font, name), index); self.state.font = Some((font.clone(), size)); } } @@ -681,13 +746,13 @@ fn write_path(ctx: &mut PageContext, x: f32, y: f32, path: &Path) { /// Encode a vector or raster image into the content stream. fn write_image(ctx: &mut PageContext, x: f32, y: f32, image: &Image, size: Size) { - let idx = ctx.parent.image_map.insert(image.clone()); + let index = ctx.parent.image_map.insert(image.clone()); ctx.parent .image_deferred_map - .entry(idx) + .entry(index) .or_insert_with(|| deferred_image(image.clone())); - let name = eco_format!("Im{idx}"); + let name = eco_format!("Im{index}"); let w = size.x.to_f32(); let h = size.y.to_f32(); ctx.content.save_state(); @@ -707,6 +772,8 @@ fn write_image(ctx: &mut PageContext, x: f32, y: f32, image: &Image, size: Size) ctx.content.x_object(Name(name.as_bytes())); } + ctx.resources + .insert(PageResource::new(ResourceKind::XObject, name.clone()), index); ctx.content.restore_state(); } diff --git a/crates/typst-pdf/src/pattern.rs b/crates/typst-pdf/src/pattern.rs new file mode 100644 index 000000000..7b9e27192 --- /dev/null +++ b/crates/typst-pdf/src/pattern.rs @@ -0,0 +1,154 @@ +use ecow::eco_format; +use pdf_writer::types::{ColorSpaceOperand, PaintType, TilingType}; +use pdf_writer::{Filter, Finish, Name, Rect}; +use typst::layout::{Abs, Transform}; +use typst::util::Numeric; +use typst::visualize::{Pattern, RelativeTo}; + +use crate::color::PaintEncode; +use crate::page::{construct_page, PageContext, PageResource, ResourceKind, Transforms}; +use crate::{deflate_memoized, transform_to_array, PdfContext}; + +/// Writes the actual patterns (tiling patterns) to the PDF. +/// This is performed once after writing all pages. +pub(crate) fn write_patterns(ctx: &mut PdfContext) { + for PdfPattern { transform, pattern, content, resources } in ctx.pattern_map.items() { + let tiling = ctx.alloc.bump(); + ctx.pattern_refs.push(tiling); + + let content = deflate_memoized(content); + let mut tiling_pattern = ctx.pdf.tiling_pattern(tiling, &content); + tiling_pattern + .tiling_type(TilingType::ConstantSpacing) + .paint_type(PaintType::Colored) + .bbox(Rect::new( + 0.0, + 0.0, + pattern.size_abs().x.to_pt() as _, + pattern.size_abs().y.to_pt() as _, + )) + .x_step((pattern.size_abs().x + pattern.spacing_abs().x).to_pt() as _) + .y_step((pattern.size_abs().y + pattern.spacing_abs().y).to_pt() as _); + + let mut resources_map = tiling_pattern.resources(); + + resources_map.x_objects().pairs( + resources + .iter() + .filter(|(res, _)| res.is_x_object()) + .map(|(res, ref_)| (res.name(), ctx.image_refs[*ref_])), + ); + + resources_map.fonts().pairs( + resources + .iter() + .filter(|(res, _)| res.is_font()) + .map(|(res, ref_)| (res.name(), ctx.font_refs[*ref_])), + ); + + ctx.colors + .write_color_spaces(resources_map.color_spaces(), &mut ctx.alloc); + + resources_map + .patterns() + .pairs( + resources + .iter() + .filter(|(res, _)| res.is_pattern()) + .map(|(res, ref_)| (res.name(), ctx.pattern_refs[*ref_])), + ) + .pairs( + resources + .iter() + .filter(|(res, _)| res.is_gradient()) + .map(|(res, ref_)| (res.name(), ctx.gradient_refs[*ref_])), + ); + + resources_map.ext_g_states().pairs( + resources + .iter() + .filter(|(res, _)| res.is_ext_g_state()) + .map(|(res, ref_)| (res.name(), ctx.ext_gs_refs[*ref_])), + ); + + resources_map.finish(); + tiling_pattern + .matrix(transform_to_array(*transform)) + .filter(Filter::FlateDecode); + } +} + +/// A pattern and its transform. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PdfPattern { + /// The transform to apply to the gradient. + pub transform: Transform, + /// The pattern to paint. + pub pattern: Pattern, + /// The rendered pattern. + pub content: Vec, + /// The resources used by the pattern. + pub resources: Vec<(PageResource, usize)>, +} + +/// Registers a pattern with the PDF. +fn register_pattern( + ctx: &mut PageContext, + pattern: &Pattern, + on_text: bool, + mut transforms: Transforms, +) -> usize { + // Edge cases for strokes. + if transforms.size.x.is_zero() { + transforms.size.x = Abs::pt(1.0); + } + + if transforms.size.y.is_zero() { + transforms.size.y = Abs::pt(1.0); + } + + let transform = match pattern.unwrap_relative(on_text) { + RelativeTo::Self_ => transforms.transform, + RelativeTo::Parent => transforms.container_transform, + }; + + // Render the body. + let (_, content) = construct_page(ctx.parent, pattern.frame()); + + let pdf_pattern = PdfPattern { + transform, + pattern: pattern.clone(), + content: content.content, + resources: content.resources.into_iter().collect(), + }; + + ctx.parent.pattern_map.insert(pdf_pattern) +} + +impl PaintEncode for Pattern { + fn set_as_fill(&self, ctx: &mut PageContext, on_text: bool, transforms: Transforms) { + ctx.reset_fill_color_space(); + + let index = register_pattern(ctx, self, on_text, transforms); + let id = eco_format!("P{index}"); + let name = Name(id.as_bytes()); + + ctx.content.set_fill_color_space(ColorSpaceOperand::Pattern); + ctx.content.set_fill_pattern(None, name); + ctx.resources + .insert(PageResource::new(ResourceKind::Pattern, id), index); + } + + fn set_as_stroke(&self, ctx: &mut PageContext, transforms: Transforms) { + ctx.reset_stroke_color_space(); + + let index = register_pattern(ctx, self, false, transforms); + let id = eco_format!("P{index}"); + let name = Name(id.as_bytes()); + + ctx.content.set_stroke_color_space(ColorSpaceOperand::Pattern); + ctx.content.set_stroke_pattern(None, name); + ctx.resources + .insert(PageResource::new(ResourceKind::Pattern, id), index); + } +} diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs index 251f647a1..5c1b8482e 100644 --- a/crates/typst-render/src/lib.rs +++ b/crates/typst-render/src/lib.rs @@ -15,8 +15,8 @@ use typst::layout::{ }; use typst::text::{Font, TextItem}; use typst::visualize::{ - Color, FixedStroke, Geometry, Gradient, GradientRelative, Image, ImageKind, LineCap, - LineJoin, Paint, Path, PathItem, RasterFormat, Shape, + Color, FixedStroke, Geometry, Gradient, Image, ImageKind, LineCap, LineJoin, Paint, + Path, PathItem, Pattern, RasterFormat, RelativeTo, Shape, }; use usvg::{NodeExt, TreeParsing}; @@ -433,7 +433,17 @@ fn render_outline_glyph( write_bitmap(canvas, &bitmap, &state, sampler)?; } Paint::Solid(color) => { - write_bitmap(canvas, &bitmap, &state, *color)?; + write_bitmap( + canvas, + &bitmap, + &state, + to_sk_color_u8_without_alpha(*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)?; } } @@ -458,7 +468,7 @@ fn write_bitmap( for x in 0..mw { for y in 0..mh { let alpha = bitmap.coverage[(y * mw + x) as usize]; - let color = to_sk_color_u8_without_alpha(sampler.sample((x, y))); + let color = sampler.sample((x, y)); pixmap.pixels_mut()[((y + 1) * (mw + 2) + (x + 1)) as usize] = sk::ColorU8::from_rgba( color.red(), @@ -504,8 +514,7 @@ fn write_bitmap( } let color = sampler.sample((x as _, y as _)); - let color = - bytemuck::cast(to_sk_color_u8_without_alpha(color).premultiply()); + let color = bytemuck::cast(color); let pi = (y * cw + x) as usize; if cov == 255 { pixels[pi] = color; @@ -746,11 +755,22 @@ fn scaled_texture(image: &Image, w: u32, h: u32) -> Option> { /// abstraction over solid colors and gradients. trait PaintSampler: Copy { /// Sample the color at the `pos` in the pixmap. - fn sample(self, pos: (u32, u32)) -> Color; + fn sample(self, pos: (u32, u32)) -> sk::PremultipliedColorU8; + + /// Write the sampler to a pixmap. + fn write_to_pixmap(self, canvas: &mut sk::Pixmap) { + let width = canvas.width(); + for x in 0..canvas.width() { + for y in 0..canvas.height() { + let color = self.sample((x, y)); + canvas.pixels_mut()[(y * width + x) as usize] = color; + } + } + } } -impl PaintSampler for Color { - fn sample(self, _: (u32, u32)) -> Color { +impl PaintSampler for sk::PremultipliedColorU8 { + fn sample(self, _: (u32, u32)) -> sk::PremultipliedColorU8 { self } } @@ -775,13 +795,13 @@ impl<'a> GradientSampler<'a> { ) -> Self { let relative = gradient.unwrap_relative(on_text); let container_size = match relative { - GradientRelative::Self_ => item_size, - GradientRelative::Parent => state.size, + RelativeTo::Self_ => item_size, + RelativeTo::Parent => state.size, }; let fill_transform = match relative { - GradientRelative::Self_ => sk::Transform::identity(), - GradientRelative::Parent => state.container_transform.invert().unwrap(), + RelativeTo::Self_ => sk::Transform::identity(), + RelativeTo::Parent => state.container_transform.invert().unwrap(), }; Self { @@ -794,16 +814,69 @@ impl<'a> GradientSampler<'a> { impl PaintSampler for GradientSampler<'_> { /// Samples a single point in a glyph. - fn sample(self, (x, y): (u32, u32)) -> Color { + 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 - self.gradient.sample_at( + to_sk_color_u8_without_alpha(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_abs() + pattern.spacing_abs()) + * 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() } } @@ -859,13 +932,13 @@ fn to_sk_paint<'a>( Paint::Gradient(gradient) => { let relative = gradient.unwrap_relative(on_text); let container_size = match relative { - GradientRelative::Self_ => item_size, - GradientRelative::Parent => state.size, + RelativeTo::Self_ => item_size, + RelativeTo::Parent => state.size, }; let fill_transform = match relative { - GradientRelative::Self_ => fill_transform.unwrap_or_default(), - GradientRelative::Parent => state + RelativeTo::Self_ => fill_transform.unwrap_or_default(), + RelativeTo::Parent => state .container_transform .post_concat(state.transform.invert().unwrap()), }; @@ -892,11 +965,49 @@ fn to_sk_paint<'a>( 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_abs() + pattern.spacing_abs(); + 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_abs(), 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_u8(); sk::Color::from_rgba8(r, g, b, a) diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs index 7d3a773d8..205cff148 100644 --- a/crates/typst-svg/src/lib.rs +++ b/crates/typst-svg/src/lib.rs @@ -14,9 +14,8 @@ use typst::layout::{ use typst::text::{Font, TextItem}; use typst::util::hash128; use typst::visualize::{ - Color, FixedStroke, Geometry, Gradient, GradientRelative, Image, ImageFormat, - LineCap, LineJoin, Paint, Path, PathItem, RasterFormat, RatioOrAngle, Shape, - VectorFormat, + Color, FixedStroke, Geometry, Gradient, Image, ImageFormat, LineCap, LineJoin, Paint, + Path, PathItem, Pattern, RasterFormat, RatioOrAngle, RelativeTo, Shape, VectorFormat, }; use xmlwriter::XmlWriter; @@ -77,6 +76,12 @@ struct SVGRenderer { /// different transforms. Therefore this allows us to reuse the same gradient /// multiple times. gradient_refs: Deduplicator, + /// Deduplicated patterns with transform matrices. They use a reference + /// (`href`) to a "source" pattern instead of being defined inline. + /// This saves a lot of space since patterns are often reused but with + /// different transforms. Therefore this allows us to reuse the same gradient + /// multiple times. + pattern_refs: Deduplicator, /// These are the actual gradients being written in the SVG file. /// These gradients are deduplicated because they do not contain the transform /// matrix, allowing them to be reused across multiple invocations. @@ -84,6 +89,12 @@ struct SVGRenderer { /// The `Ratio` is the aspect ratio of the gradient, this is used to correct /// the angle of the gradient. gradients: Deduplicator<(Gradient, Ratio)>, + /// These are the actual patterns being written in the SVG file. + /// These patterns are deduplicated because they do not contain the transform + /// matrix, allowing them to be reused across multiple invocations. + /// + /// The `String` is the rendered pattern frame. + patterns: Deduplicator, /// These are the gradients that compose a conic gradient. conic_subgradients: Deduplicator, } @@ -141,6 +152,20 @@ struct GradientRef { transform: Transform, } +/// A reference to a deduplicated pattern, with a transform matrix. +/// +/// Allows patterns to be reused across multiple invocations, +/// simply by changing the transform matrix. +#[derive(Hash)] +struct PatternRef { + /// The ID of the deduplicated gradient + id: Id, + /// The transform matrix to apply to the pattern. + transform: Transform, + /// The ratio of the size of the cell to the size of the filled area. + ratio: Axes, +} + /// A subgradient for conic gradients. #[derive(Hash)] struct SVGSubGradient { @@ -199,6 +224,8 @@ impl SVGRenderer { gradient_refs: Deduplicator::new('g'), gradients: Deduplicator::new('f'), conic_subgradients: Deduplicator::new('s'), + pattern_refs: Deduplicator::new('p'), + patterns: Deduplicator::new('t'), } } @@ -219,6 +246,20 @@ impl SVGRenderer { self.xml.write_attribute("xmlns:h5", "http://www.w3.org/1999/xhtml"); } + /// Render a frame to a string. + fn render_pattern_frame( + &mut self, + state: State, + ts: Transform, + frame: &Frame, + ) -> String { + let mut xml = XmlWriter::new(xmlwriter::Options::default()); + std::mem::swap(&mut self.xml, &mut xml); + self.render_frame(state, ts, frame); + std::mem::swap(&mut self.xml, &mut xml); + xml.end_document() + } + /// Render a frame with the given transform. fn render_frame(&mut self, state: State, ts: Transform, frame: &Frame) { self.xml.start_element("g"); @@ -286,37 +327,27 @@ impl SVGRenderer { /// of them works, we will skip the text. fn render_text(&mut self, state: State, 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), - ); + self.xml.write_attribute("transform", "scale(1, -1)"); let mut x: f64 = 0.0; for glyph in &text.glyphs { 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)) + self.render_svg_glyph(text, id, offset, scale) + .or_else(|| self.render_bitmap_glyph(text, id, offset)) .or_else(|| { self.render_outline_glyph( state - .pre_concat(Transform::scale( - Ratio::new(scale), - Ratio::new(-scale), - )) - .pre_translate(Point::new( - Abs::pt(offset / scale), - Abs::zero(), - )), + .pre_concat(Transform::scale(Ratio::one(), -Ratio::one())) + .pre_translate(Point::new(Abs::pt(offset), Abs::zero())), text, id, offset, - inv_scale, + scale, ) }); @@ -332,7 +363,7 @@ impl SVGRenderer { text: &TextItem, id: GlyphId, x_offset: f64, - inv_scale: f64, + scale: f64, ) -> Option<()> { let data_url = convert_svg_glyph_to_base64_url(&text.font, id)?; let upem = Abs::raw(text.font.units_per_em()); @@ -344,13 +375,12 @@ impl SVGRenderer { width: upem.to_pt(), height: upem.to_pt(), ts: Transform::translate(Abs::zero(), Abs::pt(-origin_ascender)) - .post_concat(Transform::scale(Ratio::new(1.0), Ratio::new(-1.0))), + .post_concat(Transform::scale(Ratio::new(scale), Ratio::new(-scale))), }); self.xml.start_element("use"); 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("x", &x_offset); self.xml.end_element(); Some(()) @@ -362,7 +392,6 @@ impl SVGRenderer { text: &TextItem, id: GlyphId, x_offset: f64, - inv_scale: f64, ) -> Option<()> { let (image, bitmap_x_offset, bitmap_y_offset) = convert_bitmap_glyph_to_image(&text.font, id)?; @@ -390,11 +419,7 @@ impl SVGRenderer { self.xml.write_attribute("x", &(x_offset / scale_factor)); self.xml.write_attribute_fmt( "transform", - format_args!( - "scale({} -{})", - inv_scale * scale_factor, - inv_scale * scale_factor, - ), + format_args!("scale({scale_factor} -{scale_factor})",), ); self.xml.end_element(); @@ -408,19 +433,23 @@ impl SVGRenderer { text: &TextItem, glyph_id: GlyphId, x_offset: f64, - inv_scale: f64, + scale: f64, ) -> Option<()> { - let path = convert_outline_glyph_to_path(&text.font, glyph_id)?; - let hash = hash128(&(&text.font, glyph_id)); + let scale = Ratio::new(scale); + let path = convert_outline_glyph_to_path(&text.font, glyph_id, scale)?; + let hash = hash128(&(&text.font, glyph_id, scale)); let id = self.glyphs.insert_with(hash, || RenderedGlyph::Path(path)); + let glyph_size = text.font.ttf().glyph_bounding_box(glyph_id)?; + let width = glyph_size.width() as f64 * scale.get(); + let height = glyph_size.height() as f64 * scale.get(); + self.xml.start_element("use"); 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_fmt("x", format_args!("{}", x_offset)); self.write_fill( &text.fill, - state.size, + Size::new(Abs::pt(width), Abs::pt(height)), self.text_paint_transform(state, &text.fill), ); self.xml.end_element(); @@ -429,17 +458,20 @@ impl SVGRenderer { } fn text_paint_transform(&self, state: State, paint: &Paint) -> Transform { - let Paint::Gradient(gradient) = paint else { - return Transform::identity(); - }; - - match gradient.unwrap_relative(true) { - GradientRelative::Self_ => Transform::scale(Ratio::one(), Ratio::one()), - GradientRelative::Parent => Transform::scale( - Ratio::new(state.size.x.to_pt()), - Ratio::new(state.size.y.to_pt()), - ) - .post_concat(state.transform.invert().unwrap()), + match paint { + Paint::Solid(_) => Transform::identity(), + Paint::Gradient(gradient) => match gradient.unwrap_relative(true) { + RelativeTo::Self_ => Transform::identity(), + RelativeTo::Parent => Transform::scale( + Ratio::new(state.size.x.to_pt()), + Ratio::new(state.size.y.to_pt()), + ) + .post_concat(state.transform.invert().unwrap()), + }, + Paint::Pattern(pattern) => match pattern.unwrap_relative(true) { + RelativeTo::Self_ => Transform::identity(), + RelativeTo::Parent => state.transform.invert().unwrap(), + }, } } @@ -490,16 +522,21 @@ impl SVGRenderer { if let Paint::Gradient(gradient) = paint { match gradient.unwrap_relative(false) { - GradientRelative::Self_ => Transform::scale( + RelativeTo::Self_ => Transform::scale( Ratio::new(shape_size.x.to_pt()), Ratio::new(shape_size.y.to_pt()), ), - GradientRelative::Parent => Transform::scale( + RelativeTo::Parent => Transform::scale( Ratio::new(state.size.x.to_pt()), Ratio::new(state.size.y.to_pt()), ) .post_concat(state.transform.invert().unwrap()), } + } else if let Paint::Pattern(pattern) = paint { + match pattern.unwrap_relative(false) { + RelativeTo::Self_ => Transform::identity(), + RelativeTo::Parent => state.transform.invert().unwrap(), + } } else { Transform::identity() } @@ -519,8 +556,8 @@ impl SVGRenderer { if let Paint::Gradient(gradient) = paint { match gradient.unwrap_relative(false) { - GradientRelative::Self_ => shape_size, - GradientRelative::Parent => state.size, + RelativeTo::Self_ => shape_size, + RelativeTo::Parent => state.size, } } else { shape_size @@ -535,6 +572,10 @@ impl SVGRenderer { let id = self.push_gradient(gradient, size, ts); self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); } + Paint::Pattern(pattern) => { + let id = self.push_pattern(pattern, size, ts); + self.xml.write_attribute_fmt("fill", format_args!("url(#{id})")); + } } } @@ -564,6 +605,29 @@ impl SVGRenderer { }) } + fn push_pattern(&mut self, pattern: &Pattern, size: Size, ts: Transform) -> Id { + let pattern_size = pattern.size_abs() + pattern.spacing_abs(); + // Unfortunately due to a limitation of `xmlwriter`, we need to + // render the frame twice: once to allocate all of the resources + // that it needs and once to actually render it. + self.render_pattern_frame( + State::new(pattern_size, Transform::identity()), + Transform::identity(), + pattern.frame(), + ); + + let pattern_id = self.patterns.insert_with(hash128(pattern), || pattern.clone()); + self.pattern_refs + .insert_with(hash128(&(pattern_id, ts)), || PatternRef { + id: pattern_id, + transform: ts, + ratio: Axes::new( + Ratio::new(pattern_size.x.to_pt() / size.x.to_pt()), + Ratio::new(pattern_size.y.to_pt() / size.y.to_pt()), + ), + }) + } + /// Write a stroke attribute. fn write_stroke( &mut self, @@ -577,6 +641,10 @@ impl SVGRenderer { let id = self.push_gradient(gradient, size, fill_transform); self.xml.write_attribute_fmt("stroke", format_args!("url(#{id})")); } + Paint::Pattern(pattern) => { + let id = self.push_pattern(pattern, size, fill_transform); + self.xml.write_attribute_fmt("stroke", format_args!("url(#{id})")); + } } self.xml.write_attribute("stroke-width", &stroke.thickness.to_pt()); @@ -630,6 +698,8 @@ impl SVGRenderer { self.write_gradients(); self.write_gradient_refs(); self.write_subgradients(); + self.write_patterns(); + self.write_pattern_refs(); self.xml.end_document() } @@ -948,12 +1018,78 @@ impl SVGRenderer { self.xml.end_element(); } + + /// Write the raw gradients (without transform) to the SVG file. + fn write_patterns(&mut self) { + if self.patterns.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "patterns"); + + for (id, pattern) in + self.patterns.iter().map(|(i, p)| (i, p.clone())).collect::>() + { + let size = pattern.size_abs() + pattern.spacing_abs(); + self.xml.start_element("pattern"); + self.xml.write_attribute("id", &id); + self.xml.write_attribute("width", &size.x.to_pt()); + self.xml.write_attribute("height", &size.y.to_pt()); + self.xml.write_attribute("patternUnits", "userSpaceOnUse"); + self.xml.write_attribute_fmt( + "viewBox", + format_args!("0 0 {:.3} {:.3}", size.x.to_pt(), size.y.to_pt()), + ); + + // Render the frame. + let state = State::new(size, Transform::identity()); + let ts = Transform::identity(); + self.render_frame(state, ts, pattern.frame()); + + self.xml.end_element(); + } + + self.xml.end_element() + } + + /// Writes the references to the deduplicated patterns for each usage site. + fn write_pattern_refs(&mut self) { + if self.pattern_refs.is_empty() { + return; + } + + self.xml.start_element("defs"); + self.xml.write_attribute("id", "pattern-refs"); + for (id, pattern_ref) in self.pattern_refs.iter() { + self.xml.start_element("pattern"); + self.xml + .write_attribute("patternTransform", &SvgMatrix(pattern_ref.transform)); + + self.xml.write_attribute("id", &id); + + // Writing the href attribute to the "reference" pattern. + self.xml + .write_attribute_fmt("href", format_args!("#{}", pattern_ref.id)); + + // Also writing the xlink:href attribute for compatibility. + self.xml + .write_attribute_fmt("xlink:href", format_args!("#{}", pattern_ref.id)); + self.xml.end_element(); + } + + self.xml.end_element(); + } } /// Convert an outline glyph to an SVG path. #[comemo::memoize] -fn convert_outline_glyph_to_path(font: &Font, id: GlyphId) -> Option { - let mut builder = SvgPathBuilder::default(); +fn convert_outline_glyph_to_path( + font: &Font, + id: GlyphId, + scale: Ratio, +) -> Option { + let mut builder = SvgPathBuilder::with_scale(scale); font.ttf().outline_glyph(id, &mut builder)?; Some(builder.0) } @@ -1170,10 +1306,17 @@ impl Display for SvgMatrix { } /// A builder for SVG path. -#[derive(Default)] -struct SvgPathBuilder(pub EcoString); +struct SvgPathBuilder(pub EcoString, pub Ratio); impl SvgPathBuilder { + fn with_scale(scale: Ratio) -> Self { + Self(EcoString::new(), scale) + } + + fn scale(&self) -> f32 { + self.1.get() as f32 + } + /// 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) { @@ -1193,34 +1336,63 @@ impl SvgPathBuilder { sweep_flag: u32, pos: (f32, f32), ) { + let scale = self.scale(); write!( &mut self.0, "A {rx} {ry} {x_axis_rot} {large_arc_flag} {sweep_flag} {x} {y} ", - rx = radius.0, - ry = radius.1, - x = pos.0, - y = pos.1, + rx = radius.0 * scale, + ry = radius.1 * scale, + x = pos.0 * scale, + y = pos.1 * scale, ) .unwrap(); } } +impl Default for SvgPathBuilder { + fn default() -> Self { + Self(Default::default(), Ratio::one()) + } +} + /// A builder for SVG path. This is used to build the path for a glyph. impl ttf_parser::OutlineBuilder for SvgPathBuilder { fn move_to(&mut self, x: f32, y: f32) { - write!(&mut self.0, "M {} {} ", x, y).unwrap(); + let scale = self.scale(); + write!(&mut self.0, "M {} {} ", x * scale, y * scale).unwrap(); } fn line_to(&mut self, x: f32, y: f32) { - write!(&mut self.0, "L {} {} ", x, y).unwrap(); + let scale = self.scale(); + write!(&mut self.0, "L {} {} ", x * scale, y * scale).unwrap(); } fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { - write!(&mut self.0, "Q {} {} {} {} ", x1, y1, x, y).unwrap(); + let scale = self.scale(); + write!( + &mut self.0, + "Q {} {} {} {} ", + x1 * scale, + y1 * scale, + x * scale, + y * scale + ) + .unwrap(); } fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { - write!(&mut self.0, "C {} {} {} {} {} {} ", x1, y1, x2, y2, x, y).unwrap(); + let scale = self.scale(); + write!( + &mut self.0, + "C {} {} {} {} {} {} ", + x1 * scale, + y1 * scale, + x2 * scale, + y2 * scale, + x * scale, + y * scale + ) + .unwrap(); } fn close(&mut self) { diff --git a/crates/typst/src/eval/ops.rs b/crates/typst/src/eval/ops.rs index 8f8e128a7..cb830614e 100644 --- a/crates/typst/src/eval/ops.rs +++ b/crates/typst/src/eval/ops.rs @@ -234,6 +234,15 @@ pub fn add(lhs: Value, rhs: Value) -> StrResult { } .into_value(), + (Pattern(pattern), Length(thickness)) | (Length(thickness), Pattern(pattern)) => { + Stroke { + paint: Smart::Custom(pattern.into()), + thickness: Smart::Custom(thickness), + ..Stroke::default() + } + .into_value() + } + (Duration(a), Duration(b)) => Duration(a + b), (Datetime(a), Duration(b)) => Datetime(a + b), (Duration(a), Datetime(b)) => Datetime(b + a), diff --git a/crates/typst/src/foundations/value.rs b/crates/typst/src/foundations/value.rs index b3141a16c..a1660e85d 100644 --- a/crates/typst/src/foundations/value.rs +++ b/crates/typst/src/foundations/value.rs @@ -21,7 +21,7 @@ use crate::layout::{Abs, Angle, Em, Fr, Length, Ratio, Rel}; use crate::symbols::Symbol; use crate::syntax::{ast, Span}; use crate::text::{RawElem, TextElem}; -use crate::visualize::{Color, Gradient}; +use crate::visualize::{Color, Gradient, Pattern}; /// A computational value. #[derive(Default, Clone)] @@ -51,6 +51,8 @@ pub enum Value { Color(Color), /// A gradient value: `gradient.linear(...)`. Gradient(Gradient), + /// A pattern fill: `pattern(...)`. + Pattern(Pattern), /// A symbol: `arrow.l`. Symbol(Symbol), /// A version. @@ -127,6 +129,7 @@ impl Value { Self::Fraction(_) => Type::of::(), Self::Color(_) => Type::of::(), Self::Gradient(_) => Type::of::(), + Self::Pattern(_) => Type::of::(), Self::Symbol(_) => Type::of::(), Self::Version(_) => Type::of::(), Self::Str(_) => Type::of::(), @@ -238,6 +241,7 @@ impl Debug for Value { Self::Fraction(v) => Debug::fmt(v, f), Self::Color(v) => Debug::fmt(v, f), Self::Gradient(v) => Debug::fmt(v, f), + Self::Pattern(v) => Debug::fmt(v, f), Self::Symbol(v) => Debug::fmt(v, f), Self::Version(v) => Debug::fmt(v, f), Self::Str(v) => Debug::fmt(v, f), @@ -274,6 +278,7 @@ impl Repr for Value { Self::Fraction(v) => v.repr(), Self::Color(v) => v.repr(), Self::Gradient(v) => v.repr(), + Self::Pattern(v) => v.repr(), Self::Symbol(v) => v.repr(), Self::Version(v) => v.repr(), Self::Str(v) => v.repr(), @@ -323,6 +328,7 @@ impl Hash for Value { Self::Fraction(v) => v.hash(state), Self::Color(v) => v.hash(state), Self::Gradient(v) => v.hash(state), + Self::Pattern(v) => v.hash(state), Self::Symbol(v) => v.hash(state), Self::Version(v) => v.hash(state), Self::Str(v) => v.hash(state), @@ -635,6 +641,7 @@ primitive! { Rel: "relative length", primitive! { Fr: "fraction", Fraction } primitive! { Color: "color", Color } primitive! { Gradient: "gradient", Gradient } +primitive! { Pattern: "pattern", Pattern } primitive! { Symbol: "symbol", Symbol } primitive! { Version: "version", Version } primitive! { diff --git a/crates/typst/src/layout/axes.rs b/crates/typst/src/layout/axes.rs index 585e66988..e5c47edd6 100644 --- a/crates/typst/src/layout/axes.rs +++ b/crates/typst/src/layout/axes.rs @@ -306,6 +306,18 @@ cast! { }, } +cast! { + Axes, + self => array![self.x, self.y].into_value(), + array: Array => { + let mut iter = array.into_iter(); + match (iter.next(), iter.next(), iter.next()) { + (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?), + _ => bail!("length array must contain exactly two entries"), + } + }, +} + impl Resolve for Axes { type Output = Axes; diff --git a/crates/typst/src/text/mod.rs b/crates/typst/src/text/mod.rs index b2a5a840d..45de35f9a 100644 --- a/crates/typst/src/text/mod.rs +++ b/crates/typst/src/text/mod.rs @@ -43,7 +43,7 @@ use crate::foundations::{ use crate::layout::{Abs, Axis, Dir, Length, Rel}; use crate::model::ParElem; use crate::syntax::Spanned; -use crate::visualize::{Color, GradientRelative, Paint}; +use crate::visualize::{Color, Paint, RelativeTo}; /// Text styling. /// @@ -226,16 +226,14 @@ pub struct TextElem { #[parse({ let paint: Option> = args.named_or_find("fill")?; if let Some(paint) = &paint { - if let Paint::Gradient(gradient) = &paint.v { - if gradient.relative() == Smart::Custom(GradientRelative::Self_) { - bail!( - error!( - paint.span, - "gradients on text must be relative to the parent" - ) - .with_hint("make sure to set `relative: auto` on your text fill") - ); - } + if paint.v.relative() == Smart::Custom(RelativeTo::Self_) { + bail!( + error!( + paint.span, + "gradients and patterns on text must be relative to the parent" + ) + .with_hint("make sure to set `relative: auto` on your text fill") + ); } } paint.map(|paint| paint.v) diff --git a/crates/typst/src/visualize/gradient.rs b/crates/typst/src/visualize/gradient.rs index bdd26c0d4..4e804d9ac 100644 --- a/crates/typst/src/visualize/gradient.rs +++ b/crates/typst/src/visualize/gradient.rs @@ -208,7 +208,7 @@ impl Gradient { /// element. #[named] #[default(Smart::Auto)] - relative: Smart, + relative: Smart, /// The direction of the gradient. #[external] #[default(Dir::LTR)] @@ -295,7 +295,7 @@ impl Gradient { /// box, column, grid, or stack that contains the element. #[named] #[default(Smart::Auto)] - relative: Smart, + relative: Smart, /// The center of the end circle of the gradient. /// /// A value of `{(50%, 50%)}` means that the end circle is @@ -409,7 +409,7 @@ impl Gradient { /// box, column, grid, or stack that contains the element. #[named] #[default(Smart::Auto)] - relative: Smart, + relative: Smart, /// The center of the last circle of the gradient. /// /// A value of `{(50%, 50%)}` means that the end circle is @@ -665,7 +665,7 @@ impl Gradient { /// Returns the relative placement of this gradient. #[func] - pub fn relative(&self) -> Smart { + pub fn relative(&self) -> Smart { match self { Self::Linear(linear) => linear.relative, Self::Radial(radial) => radial.relative, @@ -718,7 +718,7 @@ impl Gradient { impl Gradient { /// Clones this gradient, but with a different relative placement. - pub fn with_relative(mut self, relative: GradientRelative) -> Self { + pub fn with_relative(mut self, relative: RelativeTo) -> Self { match &mut self { Self::Linear(linear) => { Arc::make_mut(linear).relative = Smart::Custom(relative); @@ -815,12 +815,12 @@ impl Gradient { /// Returns the relative placement of this gradient, handling /// the special case of `auto`. - pub fn unwrap_relative(&self, on_text: bool) -> GradientRelative { + pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo { self.relative().unwrap_or_else(|| { if on_text { - GradientRelative::Parent + RelativeTo::Parent } else { - GradientRelative::Self_ + RelativeTo::Self_ } }) } @@ -870,7 +870,7 @@ pub struct LinearGradient { /// The color space in which to interpolate the gradient. pub space: ColorSpace, /// The relative placement of the gradient. - pub relative: Smart, + pub relative: Smart, /// Whether to anti-alias the gradient (used for sharp gradients). pub anti_alias: bool, } @@ -938,7 +938,7 @@ pub struct RadialGradient { /// The color space in which to interpolate the gradient. pub space: ColorSpace, /// The relative placement of the gradient. - pub relative: Smart, + pub relative: Smart, /// Whether to anti-alias the gradient (used for sharp gradients). pub anti_alias: bool, } @@ -1016,7 +1016,7 @@ pub struct ConicGradient { /// The color space in which to interpolate the gradient. pub space: ColorSpace, /// The relative placement of the gradient. - pub relative: Smart, + pub relative: Smart, /// Whether to anti-alias the gradient (used for sharp gradients). pub anti_alias: bool, } @@ -1070,7 +1070,7 @@ impl Repr for ConicGradient { /// What is the gradient relative to. #[derive(Cast, Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum GradientRelative { +pub enum RelativeTo { /// The gradient is relative to itself (its own bounding box). Self_, /// The gradient is relative to its parent (the parent's bounding box). diff --git a/crates/typst/src/visualize/mod.rs b/crates/typst/src/visualize/mod.rs index e733e5a41..744e4e85e 100644 --- a/crates/typst/src/visualize/mod.rs +++ b/crates/typst/src/visualize/mod.rs @@ -6,6 +6,7 @@ mod image; mod line; mod paint; mod path; +mod pattern; mod polygon; mod shape; mod stroke; @@ -16,6 +17,7 @@ pub use self::image::*; pub use self::line::*; pub use self::paint::*; pub use self::path::*; +pub use self::pattern::*; pub use self::polygon::*; pub use self::shape::*; pub use self::stroke::*; @@ -35,6 +37,7 @@ pub(super) fn define(global: &mut Scope) { global.category(VISUALIZE); global.define_type::(); global.define_type::(); + global.define_type::(); global.define_type::(); global.define_elem::(); global.define_elem::(); diff --git a/crates/typst/src/visualize/paint.rs b/crates/typst/src/visualize/paint.rs index ca5d0d40a..cd1006aa3 100644 --- a/crates/typst/src/visualize/paint.rs +++ b/crates/typst/src/visualize/paint.rs @@ -2,8 +2,8 @@ use std::fmt::{self, Debug, Formatter}; use ecow::EcoString; -use crate::foundations::{cast, Repr}; -use crate::visualize::{Color, Gradient, GradientRelative}; +use crate::foundations::{cast, Repr, Smart}; +use crate::visualize::{Color, Gradient, Pattern, RelativeTo}; /// How a fill or stroke should be painted. #[derive(Clone, Eq, PartialEq, Hash)] @@ -12,6 +12,8 @@ pub enum Paint { Solid(Color), /// A gradient. Gradient(Gradient), + /// A pattern. + Pattern(Pattern), } impl Paint { @@ -19,19 +21,31 @@ impl Paint { pub fn unwrap_solid(&self) -> Color { match self { Self::Solid(color) => *color, - Self::Gradient(_) => panic!("expected solid color"), + Self::Gradient(_) | Self::Pattern(_) => panic!("expected solid color"), + } + } + + /// Gets the relative coordinate system for this paint. + pub fn relative(&self) -> Smart { + match self { + Self::Solid(_) => Smart::Auto, + Self::Gradient(gradient) => gradient.relative(), + Self::Pattern(pattern) => pattern.relative(), } } /// Turns this paint into a paint for a text decoration. /// /// If this paint is a gradient, it will be converted to a gradient with - /// relative set to [`GradientRelative::Parent`]. + /// relative set to [`RelativeTo::Parent`]. pub fn as_decoration(&self) -> Self { match self { Self::Solid(color) => Self::Solid(*color), Self::Gradient(gradient) => { - Self::Gradient(gradient.clone().with_relative(GradientRelative::Parent)) + Self::Gradient(gradient.clone().with_relative(RelativeTo::Parent)) + } + Self::Pattern(pattern) => { + Self::Pattern(pattern.clone().with_relative(RelativeTo::Parent)) } } } @@ -42,15 +56,23 @@ impl Debug for Paint { match self { Self::Solid(v) => v.fmt(f), Self::Gradient(v) => v.fmt(f), + Self::Pattern(v) => v.fmt(f), } } } +impl From for Paint { + fn from(pattern: Pattern) -> Self { + Self::Pattern(pattern) + } +} + impl Repr for Paint { fn repr(&self) -> EcoString { match self { Self::Solid(color) => color.repr(), Self::Gradient(gradient) => gradient.repr(), + Self::Pattern(pattern) => pattern.repr(), } } } @@ -72,7 +94,9 @@ cast! { self => match self { Self::Solid(color) => color.into_value(), Self::Gradient(gradient) => gradient.into_value(), + Self::Pattern(pattern) => pattern.into_value(), }, color: Color => Self::Solid(color), gradient: Gradient => Self::Gradient(gradient), + pattern: Pattern => Self::Pattern(pattern), } diff --git a/crates/typst/src/visualize/pattern.rs b/crates/typst/src/visualize/pattern.rs new file mode 100644 index 000000000..55d685006 --- /dev/null +++ b/crates/typst/src/visualize/pattern.rs @@ -0,0 +1,288 @@ +use std::hash::Hash; +use std::sync::Arc; + +use comemo::Prehashed; +use ecow::{eco_format, EcoString}; + +use crate::diag::{bail, error, SourceResult}; +use crate::eval::Vm; +use crate::foundations::{func, scope, ty, Content, Repr, Smart, StyleChain}; +use crate::layout::{Abs, Axes, Em, Frame, Layout, Length, Regions, Size}; +use crate::syntax::{Span, Spanned}; +use crate::util::Numeric; +use crate::visualize::RelativeTo; +use crate::World; + +/// A repeating pattern fill. +/// +/// Typst supports the most common pattern type of tiled patterns, where a +/// pattern is repeated in a grid-like fashion. The pattern is defined by a +/// body and a tile size. The tile size is the size of each cell of the pattern. +/// The body is the content of each cell of the pattern. The pattern is +/// repeated in a grid-like fashion covering the entire area of the element +/// being filled. You can also specify a spacing between the cells of the +/// pattern, which is defined by a horizontal and vertical spacing. The spacing +/// is the distance between the edges of adjacent cells of the pattern. The default +/// spacing is zero. +/// +/// # Examples +/// +/// ```example +/// #let pat = pattern(size: (30pt, 30pt))[ +/// #place(top + left, line(start: (0%, 0%), end: (100%, 100%), stroke: 1pt)) +/// #place(top + left, line(start: (0%, 100%), end: (100%, 0%), stroke: 1pt)) +/// ] +/// +/// #rect(fill: pat, width: 100%, height: 100%, stroke: 1pt) +/// ``` +/// +/// Patterns are also supported on text, but only when setting the +/// [relativeness]($pattern.relative) to either `{auto}` (the default value) or +/// `{"parent"}`. To create word-by-word or glyph-by-glyph patterns, you can +/// wrap the words or characters of your text in [boxes]($box) manually or +/// through a [show rule]($styling/#show-rules). +/// +/// ```example +/// #let pat = pattern( +/// size: (30pt, 30pt), +/// relative: "parent", +/// square(size: 30pt, fill: gradient.conic(..color.map.rainbow)) +/// ) +/// +/// #set text(fill: pat) +/// #lorem(10) +/// ``` +/// +/// You can also space the elements further or closer apart using the +/// [`spacing`]($pattern.spacing) feature of the pattern. If the spacing +/// is lower than the size of the pattern, the pattern will overlap. +/// If it is higher, the pattern will have gaps of the same color as the +/// background of the pattern. +/// +/// ```example +/// #let pat = pattern( +/// size: (30pt, 30pt), +/// spacing: (10pt, 10pt), +/// relative: "parent", +/// square(size: 30pt, fill: gradient.conic(..color.map.rainbow)) +/// ) +/// +/// #rect(width: 100%, height: 100%, fill: pat) +/// ``` +/// +/// # Relativeness +/// The location of the starting point of the pattern is dependant on the +/// dimensions of a container. This container can either be the shape they +/// are painted on, or the closest surrounding container. This is controlled by +/// the `relative` argument of a pattern constructor. By default, patterns are +/// relative to the shape they are painted on, unless the pattern is applied on +/// text, in which case they are relative to the closest ancestor container. +/// +/// Typst determines the ancestor container as follows: +/// - For shapes that are placed at the root/top level of the document, the +/// closest ancestor is the page itself. +/// - For other shapes, the ancestor is the innermost [`block`]($block) or +/// [`box`]($box) that contains the shape. This includes the boxes and blocks +/// that are implicitly created by show rules and elements. For example, a +/// [`rotate`]($rotate) will not affect the parent of a gradient, but a +/// [`grid`]($grid) will. +#[ty(scope)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Pattern(Arc); + +/// Internal representation of [`Pattern`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PatternRepr { + /// The body of the pattern + body: Prehashed, + /// The pattern's rendered content. + frame: Prehashed, + /// The pattern's tile size. + size: Size, + /// The pattern's tile spacing. + spacing: Size, + /// The pattern's relative transform. + relative: Smart, +} + +#[scope] +impl Pattern { + /// Construct a new pattern. + /// + /// ```example + /// #let pat = pattern( + /// size: (20pt, 20pt), + /// relative: "parent", + /// place(dx: 5pt, dy: 5pt, rotate(45deg, square(size: 5pt, fill: black))) + /// ) + /// + /// #rect(width: 100%, height: 100%, fill: pat) + /// ``` + #[func(constructor)] + pub fn construct( + vm: &mut Vm, + /// The bounding box of each cell of the pattern. + #[named] + #[default(Spanned::new(Smart::Auto, Span::detached()))] + size: Spanned>>, + /// The spacing between cells of the pattern. + #[named] + #[default(Spanned::new(Axes::splat(Length::zero()), Span::detached()))] + spacing: Spanned>, + /// The [relative placement](#relativeness) of the pattern. + /// + /// For an element placed at the root/top level of the document, the + /// parent is the page itself. For other elements, the parent is the + /// innermost block, box, column, grid, or stack that contains the + /// element. + #[named] + #[default(Smart::Auto)] + relative: Smart, + /// The content of each cell of the pattern. + body: Content, + ) -> SourceResult { + let span = size.span; + if let Smart::Custom(size) = size.v { + // Ensure that sizes are absolute. + if !size.x.em.is_zero() || !size.y.em.is_zero() { + bail!(span, "pattern tile size must be absolute"); + } + + // Ensure that sizes are non-zero and finite. + if size.x.is_zero() + || size.y.is_zero() + || !size.x.is_finite() + || !size.y.is_finite() + { + bail!(span, "pattern tile size must be non-zero and non-infinite"); + } + } + + // Ensure that spacing is absolute. + if !spacing.v.x.em.is_zero() || !spacing.v.y.em.is_zero() { + bail!(spacing.span, "pattern tile spacing must be absolute"); + } + + // Ensure that spacing is finite. + if !spacing.v.x.is_finite() || !spacing.v.y.is_finite() { + bail!(spacing.span, "pattern tile spacing must be finite"); + } + + // The size of the frame + let size = size.v.map(|l| l.map(|a| a.abs)); + let region = size.unwrap_or_else(|| Axes::splat(Abs::inf())); + + // Layout the pattern. + let world = vm.vt.world; + let library = world.library(); + let styles = StyleChain::new(&library.styles); + let pod = Regions::one(region, Axes::splat(false)); + let mut frame = body.layout(&mut vm.vt, styles, pod)?.into_frame(); + + // Check that the frame is non-zero. + if size.is_auto() && frame.size().is_zero() { + bail!(error!(span, "pattern tile size must be non-zero") + .with_hint("try setting the size manually")); + } + + // Set the size of the frame if the size is enforced. + if let Smart::Custom(size) = size { + frame.set_size(size); + } + + Ok(Self(Arc::new(PatternRepr { + size: frame.size(), + body: Prehashed::new(body), + frame: Prehashed::new(frame), + spacing: spacing.v.map(|l| l.abs), + relative, + }))) + } + + /// Returns the content of an individual tile of the pattern. + #[func] + pub fn body(&self) -> Content { + self.0.body.clone().into_inner() + } + + /// Returns the size of an individual tile of the pattern. + #[func] + pub fn size(&self) -> Axes { + self.0.size.map(|l| Length { abs: l, em: Em::zero() }) + } + + /// Returns the spacing between tiles of the pattern. + #[func] + pub fn spacing(&self) -> Axes { + self.0.spacing.map(|l| Length { abs: l, em: Em::zero() }) + } + + /// Returns the relative placement of the pattern. + #[func] + pub fn relative(&self) -> Smart { + self.0.relative + } +} + +impl Pattern { + /// Set the relative placement of the pattern. + pub fn with_relative(mut self, relative: RelativeTo) -> Self { + if let Some(this) = Arc::get_mut(&mut self.0) { + this.relative = Smart::Custom(relative); + } else { + self.0 = Arc::new(PatternRepr { + relative: Smart::Custom(relative), + ..self.0.as_ref().clone() + }); + } + + self + } + + /// Returns the relative placement of the pattern. + pub fn unwrap_relative(&self, on_text: bool) -> RelativeTo { + self.0.relative.unwrap_or_else(|| { + if on_text { + RelativeTo::Parent + } else { + RelativeTo::Self_ + } + }) + } + + /// Return the size of the pattern in absolute units. + pub fn size_abs(&self) -> Size { + self.0.size + } + + /// Return the spacing of the pattern in absolute units. + pub fn spacing_abs(&self) -> Size { + self.0.spacing + } + + /// Return the frame of the pattern. + pub fn frame(&self) -> &Frame { + &self.0.frame + } +} + +impl Repr for Pattern { + fn repr(&self) -> EcoString { + let mut out = + eco_format!("pattern(({}, {})", self.0.size.x.repr(), self.0.size.y.repr()); + + if self.spacing() != Axes::splat(Length::zero()) { + out.push_str(", spacing: ("); + out.push_str(&self.0.spacing.x.repr()); + out.push_str(", "); + out.push_str(&self.0.spacing.y.repr()); + out.push(')'); + } + + out.push_str(", "); + out.push_str(&self.0.body.repr()); + out.push(')'); + + out + } +} diff --git a/crates/typst/src/visualize/stroke.rs b/crates/typst/src/visualize/stroke.rs index 3a90c3b95..cc93cee7a 100644 --- a/crates/typst/src/visualize/stroke.rs +++ b/crates/typst/src/visualize/stroke.rs @@ -7,7 +7,7 @@ use crate::foundations::{ }; use crate::layout::{Abs, Length}; use crate::util::{Numeric, Scalar}; -use crate::visualize::{Color, Gradient, Paint}; +use crate::visualize::{Color, Gradient, Paint, Pattern}; /// Defines how to draw a line. /// @@ -381,6 +381,10 @@ cast! { paint: Smart::Custom(gradient.into()), ..Default::default() }, + pattern: Pattern => Self { + paint: Smart::Custom(pattern.into()), + ..Default::default() + }, mut dict: Dict => { // Get a value by key, accepting either Auto or something convertible to type T. fn take(dict: &mut Dict, key: &str) -> StrResult> { diff --git a/tests/ref/visualize/pattern-relative.png b/tests/ref/visualize/pattern-relative.png new file mode 100644 index 0000000000000000000000000000000000000000..7958bf7f64b731287f77802840506e986bb12c24 GIT binary patch literal 1548 zcmeAS@N?(olHy`uVBq!ia0vp^cNiEL*Re1IS)4CR&j2Zv0G|+7ApQUU|M&0TKX~xK zz`#IXUtd>OS4T%jOG`^rQ#z7O4O*#o z=HF7LfvZhg1rw%>CEIDTxNk+l1Fi^Bo`qawF;IU5_iO^=p=sn#;a zLctAll6Jee94P8~D6`L>K z-_B<@b=~&v>I%Yo-X+d;x}9y_qq7(s>icQp7f%bU1ujYCIS;U|U~EwH%L zmhQ>y4}w=lFVWQ&SQI<0&gEZVf+#DN-Xm<7t8ijCz>Hj?r16VLrf`H z-D5Ljm+P%fOb6E*eM&$md^z12WEfcG`EZbh1<@a#e4O$l=T;P0Ld>S}j*8vfIQOcP zj5{APzJ7Vu$6;wQ`;%!6AF5C8oUvu=Lgpfn;q%tD^PK>hU1h@~J0Uvt$-yhRFB%{& zaAtoJCRu4vDs80zcHHd?j5|Z!` z2hxM$Z#MmAsOS0N^XiP4%%jaKjiU=X&M!Ncb5PN4w$)Zv_8&Q?@(zmbo*Z4^{dQ~W z_6O5$S-0P4T73WK458?PKhLFtleqipvJ$;=4t_kJ;?t>X@k^S$`$p5_{r(#}fSfH2 zu0WY|MIE3ryO)WAKs}aX*{{W9l$ic~EBUL&;=)p+^fu?<*{th@4wvK3^fAx+687?L zi^1#W3xy7w?Vj1Po4i!KR_riY?qV#v$#d=%uN$7#?ENNtVaj3W`;5MpQoJ%3dYbPa zU_5Jatxx7ci}UXjjJ^_{ZG0Jr4wx1>2p>E7(3eBF?P(+PtPjBlE-?E3Uif&+>GKvv x4%COQSLPkQh8EK$;&Xt4xi_??X5IhA%e+r+-ONXmF9C~n22WQ%mvv4FO#n53(>wqG literal 0 HcmV?d00001 diff --git a/tests/ref/visualize/pattern-small.png b/tests/ref/visualize/pattern-small.png new file mode 100644 index 0000000000000000000000000000000000000000..6af592dd8def90a24360245f05af42e2d19616a2 GIT binary patch literal 106 zcmeAS@N?(olHy`uVBq!ia0y~yU}OQZT^NA`gMUm}J&-c=ba4!+xRoryYQpgC|Hnq= z#aeEmJVK)N5;HhgOK8M3=DF8Q=e#9-d6@0;_2$=vd$@? F2>?H<9peB1 literal 0 HcmV?d00001 diff --git a/tests/ref/visualize/pattern-spacing.png b/tests/ref/visualize/pattern-spacing.png new file mode 100644 index 0000000000000000000000000000000000000000..4c95a3b027492e64570241eca601973b0498baf9 GIT binary patch literal 307 zcmeAS@N?(olHy`uVBq!ia0vp^cNiF$8km@Ytgh$(wgV~70G|+7ApQUUe}_2%w9*OP4+8@9In@s9U97C-MUo(DD1;=TU8cnhHJXBK|o0$< z-}xqg!^?yf&;92=ahU$de%d33lAG;I9_$4w`sL~37*cWT?HxnDCI^AG!1WtiMeZGt zmaty`Oi<_L2E%ijwhTc$GkF{L?7zYHS#`qowd%&k(J{9(gj75xC^$B?ENbyyb1P)l z2TA2k?uCbb%T2WH5|(@3@g{Kghpe|!FBh9Uym?FalKaPQ38hWPKiVCCFEP<>S@T7c z-v^Hfms+U_SG}0w^I~DL(ZcS&oh^D+&xC*ib%KW<+8v)++No}~xU;WhiL&fV4|l6e z6G>neid*f{e!Q+{x6!*(JG4KiPyRgx;^IT+g#VaMoO$z6osHAqto`2E4!}@g@O1Ta JS?83{1OT;uo$mku literal 0 HcmV?d00001 diff --git a/tests/ref/visualize/pattern-text.png b/tests/ref/visualize/pattern-text.png new file mode 100644 index 0000000000000000000000000000000000000000..2ecf2fdabcb1069dd45f0ea24c72c0c9f7e3ae00 GIT binary patch literal 29319 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