mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 01:37:02 -06:00
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:
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user