Files
vectorcraft/crates/engine/src/tests_pdffidelity.rs
T
bflatasticandClaude Opus 5.5 ffc60d5ff8 M4.59: pattern strokes and freeform gradients keep their look in PDF
Pattern strokes are written as the pattern's tiles clipped to the stroke's
outline (dashes, caps, profile, arrowheads and alignment included) instead
of mid-grey; freeform gradients on fills, strokes and type as an image of
their colour field at the document raster effects resolution, clipped to
what they paint, instead of their average colour. Both read back within
2% of the canvas at 150 ppi with no warning. Text strokes are covered for
cap, miter limit, dash array and alignment; knockout groups keep their
soft-masked form with a warning. The freeform box and grid sampling are
shared with the renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:52:35 +07:00

96 lines
4.6 KiB
Rust

//! 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
//! both read back looking like the canvas (within 2% at 150 ppi), with nothing to report.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, Node};
use vectorcraft_geom::Rect;
use vectorcraft_render::{Rendered, Renderer};
use super::*;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn node(s: &Session, id: &Value) -> Node {
s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone()
}
/// `s`'s document with object `id`'s fills and strokes repainted.
fn repainted(s: &Session, id: &Value, fill: Option<Paint>, stroke: Option<Paint>) -> Document {
let mut d = (*s.doc().unwrap().doc).clone();
for item in &mut d.node_mut(NodeId(id.as_u64().unwrap())).unwrap().appearance.items {
match (item, &fill, &stroke) {
(AppearanceItem::Fill(f), Some(p), _) => f.paint = p.clone(),
(AppearanceItem::Stroke(st), _, Some(p)) => st.paint = p.clone(),
_ => {}
}
}
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 150.0 / 72.0, true)
}
/// Mean difference of the colour channels of two renders, as a share of full scale.
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
sum as f64 / a.pixels.len() as f64 / 255.0
}
/// The PDF of `s`'s document: no warnings, an embedded image when `image`, and read back within 2%
/// of the canvas and much closer than `rough` (the art painted the old way).
fn assert_faithful(what: &str, s: &Session, image: bool, rough: &Document) {
let d = &s.doc().unwrap().doc;
let r = crate::cmd::rasterfx::export_pdf_with_report(d, &Default::default()).unwrap();
assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings);
assert_eq!(String::from_utf8_lossy(&r.bytes).contains("/Subtype/Image"), image, "{what}: image XObject");
let canvas = render(d);
let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&r.bytes).unwrap()));
let before = mean_diff(&canvas, &render(rough));
assert!(diff < 0.02 && diff < before / 4.0, "{what}: read back {:.2}% off (the old way {:.2}%)", diff * 100.0, before * 100.0);
}
#[test]
fn pattern_strokes_are_tiles_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();
run(&mut s, "select.set", json!({"ids": [dot]}));
run(&mut s, "paint.setFill", json!({"color": "#d02020"}));
run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 8, "height": 8}));
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!({"color": "#2040c0"}));
run(&mut s, "stroke.set", json!({"weight": 16, "align": "outside", "dash": [30, 10]}));
run(&mut s, "paint.setStroke", json!({"swatch": "Dots"}));
// The old way: a mid-grey stroke.
let grey = Paint::solid(Color::rgb(0.5, 0.5, 0.5));
assert_faithful("pattern stroke", &s, false, &repainted(&s, &frame, None, Some(grey)));
}
#[test]
fn freeform_gradients_are_images_of_their_colour_field() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let id = run(&mut s, "shape.ellipse", json!({"x": 20, "y": 30, "width": 160, "height": 130}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [id]}));
run(&mut s, "stroke.set", json!({"weight": 14}));
run(&mut s, "paint.editGradient", json!({"kind": "freeform"}));
run(&mut s, "paint.editGradient", json!({"kind": "freeform", "stroke": true}));
// The old way: the average colour.
let Paint::Gradient(g) = node(&s, &id).appearance.fill_paint() else { panic!("a freeform fill") };
let n = g.gradient.stops.len() as f32;
let c = g.gradient.stops.iter().fold([0.0f32; 3], |acc, st| {
let c = st.color.to_rgb();
[acc[0] + c[0] / n, acc[1] + c[1] / n, acc[2] + c[2] / n]
});
let avg = Paint::solid(Color::rgb(c[0], c[1], c[2]));
let rough = repainted(&s, &id, Some(avg.clone()), Some(avg));
assert_faithful("freeform", &s, true, &rough);
}