Merge pull request #3905 from storytold/feat/3849-pdf-tiling-patterns

PDF export: pattern fills are written as tiling patterns (#3849)
This commit is contained in:
BFlat
2026-10-24 06:22:45 +07:00
committed by GitHub
4 changed files with 149 additions and 26 deletions
+55 -7
View File
@@ -419,15 +419,63 @@ fn svg_export_writes_pattern_def() {
assert!(svg[start..end].contains("<path"));
}
/// A pattern fill is written as a PDF tiling pattern, which PDF import reads back as a pattern
/// swatch: a rotated hatch over a long bar (#3849) stays one object with one pattern fill, in a few
/// kilobytes, instead of hundreds of tiles.
#[test]
fn pdf_export_expands_pattern_fills() {
let mut s = session();
make_dots(&mut s);
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
let rep = vectorcraft_pdf::export_with_report(doc(&s), &Default::default()).unwrap();
fn pdf_export_writes_pattern_fills_as_tiling_patterns() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="2812" height="300" viewBox="0 0 2812 300">
<defs>
<pattern id="hatch" patternUnits="userSpaceOnUse" width="24" height="24" patternTransform="rotate(45)">
<rect width="12" height="24" fill="#EB003C"/>
<rect x="12" width="12" height="24" fill="#FFFFFF"/>
</pattern>
</defs>
<rect x="80" y="80" width="2650" height="140" fill="url(#hatch)" stroke="#EB003C" stroke-width="4"/>
</svg>"##;
let d = vectorcraft_svg::import(svg).unwrap();
let rep = vectorcraft_pdf::export_with_report(&d, &Default::default()).unwrap();
assert!(rep.warnings.iter().all(|w| !w.contains("pattern")), "{:?}", rep.warnings);
assert!(rep.bytes.len() > 500);
assert!(rep.bytes.len() < 8_000, "{} bytes", rep.bytes.len());
let plain = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
assert!(String::from_utf8_lossy(&plain).contains("/PatternType 1"));
let back = vectorcraft_pdf::import(&rep.bytes).unwrap();
assert_eq!(back.patterns.len(), 1);
assert_eq!(back.patterns[0].art.len(), 2, "the hatch's two stripes");
let mut leaves = vec![];
back.walk(|n| {
if !n.is_container() {
leaves.push(n.appearance.fill_paint());
}
});
assert!(leaves.len() <= 2, "{leaves:?}");
assert!(leaves.iter().any(|p| matches!(p, Paint::Pattern { pattern, .. } if *pattern == back.patterns[0].name)), "{leaves:?}");
}
/// A pattern fill nested more than four patterns deep is written as its tiles, and the export says so.
#[test]
fn pdf_export_writes_patterns_nested_too_deep_as_tiles_with_a_warning() {
use std::sync::Arc;
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_doc::{Appearance, Document};
let square = |paint: Paint| {
Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))
};
let mut d = Document::new(100.0, 100.0);
d.patterns.push(PatternDef::new("P0", vec![Arc::new(square(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))))]));
for i in 1..=4 {
d.patterns.push(PatternDef::new(&format!("P{i}"), vec![Arc::new(square(pattern_paint(&format!("P{}", i - 1))))]));
}
// P3 nests four tiling patterns (P3 to P0); P4 one more, which is written as its tiles.
for (top, tiles) in [("P3", false), ("P4", true)] {
let mut d = d.clone();
let mut n = square(pattern_paint(top));
n.id = d.alloc_id();
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n).unwrap();
let rep = vectorcraft_pdf::export_with_report(&d, &Default::default()).unwrap();
assert_eq!(rep.warnings.iter().any(|w| w.contains("are written as their tiles")), tiles, "{top}: {:?}", rep.warnings);
}
}
#[test]
+20 -3
View File
@@ -1,5 +1,5 @@
//! PDF vector fidelity: pattern strokes are written as their tiles clipped to the stroke's
//! outline, freeform gradients as images of their colour field clipped to what they paint, and
//! PDF vector fidelity: pattern fills and strokes are written as tiling patterns (strokes clipped
//! to the stroke's outline), freeform gradients as images of their colour field clipped to what they paint, and
//! both read back looking like the canvas (within 2% at 150 ppi), with nothing to report.
use serde_json::{Value, json};
@@ -55,7 +55,7 @@ fn assert_faithful(what: &str, s: &Session, image: bool, rough: &Document) {
}
#[test]
fn pattern_strokes_are_tiles_clipped_to_the_stroke() {
fn pattern_strokes_are_tiling_patterns_clipped_to_the_stroke() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 6, "height": 6}))["id"].clone();
@@ -73,6 +73,23 @@ fn pattern_strokes_are_tiles_clipped_to_the_stroke() {
assert_faithful("pattern stroke", &s, false, &repainted(&s, &frame, None, Some(grey)));
}
/// A brick tiling whose art reaches over the next tiles repeats as one period of the tiling (#3849).
#[test]
fn brick_tilings_with_overlapping_tiles_are_tiling_patterns() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [dot]}));
run(&mut s, "paint.setFill", json!({"color": "#d02020"}));
run(&mut s, "object.pattern.make", json!({"name": "Bricks", "tileType": "brickByRow", "width": 10, "height": 10}));
run(&mut s, "object.pattern.done", json!({}));
let frame = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 140, "height": 120}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [frame]}));
run(&mut s, "paint.setFill", json!({"swatch": "Bricks"}));
let grey = Paint::solid(Color::rgb(0.5, 0.5, 0.5));
assert_faithful("brick fill", &s, false, &repainted(&s, &frame, Some(grey), None));
}
#[test]
fn freeform_gradients_are_images_of_their_colour_field() {
let mut s = Session::new();
+72 -15
View File
@@ -621,6 +621,8 @@ pub(crate) struct Exporter<'a> {
/// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in
/// text…): deeper ones are left out.
inline_depth: u32,
/// Tiling patterns being drawn inside tiling patterns ([`Self::tiling`]).
pattern_depth: u32,
/// Keys of the images left out because the writer couldn't decode them
/// ([`leaving_out_undecodable`]).
pub left_out: HashSet<String>,
@@ -629,6 +631,9 @@ pub(crate) struct Exporter<'a> {
/// How deep inline graphics nest before they are left out.
const MAX_INLINE_DEPTH: u32 = 4;
/// How deep tiling patterns nest: deeper pattern fills are written as their tiles.
const MAX_PATTERN_DEPTH: u32 = 4;
/// The warning for an image left out because it can't be decoded: the note raster exports give
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
@@ -664,6 +669,7 @@ impl<'a> Exporter<'a> {
overprinted: false,
invisible: false,
inline_depth: 0,
pattern_depth: 0,
left_out: HashSet::new(),
}
}
@@ -1304,9 +1310,9 @@ impl Exporter<'_> {
/// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity,
/// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a
/// pattern's tile instances covering it, or an image of a gradient drawn as pixels placed on
/// `bounds` (the painted object's box, as on the canvas), clipped to it. False (nothing drawn)
/// for other paints.
/// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image
/// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the
/// canvas), clipped to it. False (nothing drawn) for other paints.
#[allow(clippy::too_many_arguments)]
fn area(
&mut self,
@@ -1323,26 +1329,40 @@ impl Exporter<'_> {
return false;
}
let (opacity, mode) = transparency;
let tiling = match p {
Paint::Pattern { pattern, xf } => self.tiling(s, pattern, *xf),
_ => None,
};
let mut pushes = 0;
if mode != BlendMode::Normal {
s.push_blend_mode(blend(mode));
pushes += 1;
}
s.push_clip_path(clip, &rule);
pushes += 1;
if opacity < 1.0 {
s.push_opacity(norm(opacity));
if let Some(paint) = tiling {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule }));
s.draw_path(clip);
s.set_fill(None);
} else {
s.push_clip_path(clip, &rule);
pushes += 1;
}
let doc = self.doc;
match p {
Paint::Pattern { pattern, xf } => {
for inst in doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default() {
self.node(s, &inst, area, true);
if opacity < 1.0 {
s.push_opacity(norm(opacity));
pushes += 1;
}
let doc = self.doc;
match p {
Paint::Pattern { pattern, xf } => {
if let Some(def) = doc.pattern(pattern) {
self.warn("pattern fills nested more than 4 patterns deep, or with tiles too large for PDF, are written as their tiles");
for inst in def.instances_in(*xf, area) {
self.node(s, &inst, area, true);
}
}
}
Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill),
_ => {}
}
Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill),
_ => {}
}
for _ in 0..pushes {
s.pop();
@@ -1350,6 +1370,43 @@ impl Exporter<'_> {
true
}
/// Pattern swatch `name` placed by `at` (pattern space → the current space) as a PDF tiling
/// pattern (ISO 32000-1, 8.7.3.1): one period of the tiling ([`vectorcraft_doc::PatternDef::period`],
/// what the canvas repeats too) with every tile instance reaching into it, in paint order, so
/// brick and hex tilings and overlapping tiles repeat as they are; clipped to the period when
/// art reaches out of it. `None` for a pattern nested deeper than [`MAX_PATTERN_DEPTH`], a period
/// or placement beyond [`MAX_COORD`] or a placement that flattens the tiles.
fn tiling(&mut self, s: &mut Surface, name: &str, at: Affine) -> Option<krilla::paint::Paint> {
let doc = self.doc;
let def = doc.pattern(name)?;
let (pw, ph) = def.period();
if self.pattern_depth >= MAX_PATTERN_DEPTH || !in_range(&[pw, ph]) || !in_range(&at.as_coeffs()) || at.determinant().abs() <= 1e-12 {
return None;
}
let cell = Rect::new(0.0, 0.0, pw, ph);
let tiles = def.instances_in(Affine::IDENTITY, cell);
let mut sb = s.stream_builder();
let mut cs = sb.surface();
let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b));
let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten();
if let Some(c) = &clip {
cs.push_clip_path(c, &krilla::paint::FillRule::NonZero);
}
// Pattern art is a picture of its own: it takes no part in a knockout group around the object.
let knockout = std::mem::take(&mut self.knockout);
self.pattern_depth += 1;
for t in &tiles {
self.node(&mut cs, t, cell, true);
}
self.pattern_depth -= 1;
self.knockout = knockout;
if clip.is_some() {
cs.pop();
}
cs.finish();
Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into())
}
/// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's
/// raster effects resolution where it is drawn (`area` as the current transform sizes it, so
/// scaled type gets its pixels too): a freeform gradient's colour field (at most
File diff suppressed because one or more lines are too long