mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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>
96 lines
4.6 KiB
Rust
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);
|
|
}
|