mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
EPS: preserve editable Illustrator art and filter raster page comparison
This commit is contained in:
committed by
bflatastic
parent
26644850fc
commit
4ed644d98d
@@ -120,3 +120,6 @@ sync_github_name = false
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.theromis]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -96,8 +96,24 @@ impl MeshReader {
|
||||
let [n0, n1, n2, n3] = corners else { return None };
|
||||
// (anchor, towards the next corner, towards the previous one, colour, opacity)
|
||||
let read = |n: &Vec<f64>| -> Option<(Point, Vec2, Vec2, Color, f32)> {
|
||||
let comps = n.len().checked_sub(9)?;
|
||||
let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None };
|
||||
// Older editing streams omit opacity; the colour space determines the number
|
||||
// of colour components, so a coordinate must never be mistaken for opacity.
|
||||
let comps = match self.space.as_str() {
|
||||
"DeviceRGB" => 3,
|
||||
"DeviceCMYK" => 4,
|
||||
"DeviceGray" => 1,
|
||||
_ => return None,
|
||||
};
|
||||
let (opacity, geometry) = match n.get(comps..)? {
|
||||
geometry if geometry.len() == 8 => (1.0, geometry),
|
||||
[opacity, geometry @ ..] if geometry.len() == 8 => (*opacity, geometry),
|
||||
_ => return None,
|
||||
};
|
||||
let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None };
|
||||
// Non-unit rational weights need a different surface representation.
|
||||
if nw != 1.0 || pw != 1.0 {
|
||||
return None;
|
||||
}
|
||||
if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(super) struct Obj {
|
||||
pub entries: Vec<(Option<String>, Vec<V>)>,
|
||||
/// Values closed without a key.
|
||||
pub content: Vec<V>,
|
||||
/// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary.
|
||||
pub art: Option<std::sync::Arc<vectorcraft_doc::Node>>,
|
||||
}
|
||||
|
||||
impl Obj {
|
||||
|
||||
@@ -272,6 +272,7 @@ enum Kind {
|
||||
Group,
|
||||
Compound,
|
||||
Clip,
|
||||
Mask,
|
||||
Obj(Box<Obj>),
|
||||
}
|
||||
|
||||
@@ -290,6 +291,8 @@ struct Frame {
|
||||
base: usize,
|
||||
/// The clipping paths' indexes among the children.
|
||||
clips: Vec<usize>,
|
||||
/// A compound member group explicitly declares its subpaths' winding with `D`.
|
||||
compound_members: bool,
|
||||
}
|
||||
|
||||
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
|
||||
@@ -383,6 +386,8 @@ struct Reader<'a> {
|
||||
clip_fills: HashSet<NodeId>,
|
||||
/// Groups closed as an object's drawn look (`9 () XW`).
|
||||
looks: HashSet<NodeId>,
|
||||
/// Legacy compound member groups, even when followed by the generic `9 () XW` marker.
|
||||
compound_groups: HashSet<NodeId>,
|
||||
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
|
||||
mesh: Option<(super::mesh::MeshReader, bool)>,
|
||||
/// The art styles (`%AI9_BeginArtStyles`), by name.
|
||||
@@ -425,9 +430,6 @@ const IGNORED: &[&str] = &[
|
||||
"Xh",
|
||||
"XH",
|
||||
"XF",
|
||||
"D",
|
||||
"X=",
|
||||
"X+",
|
||||
"Bc",
|
||||
"XP",
|
||||
"Np",
|
||||
@@ -529,6 +531,7 @@ impl<'a> Reader<'a> {
|
||||
hidden_ids: HashSet::new(),
|
||||
clip_fills: HashSet::new(),
|
||||
looks: HashSet::new(),
|
||||
compound_groups: HashSet::new(),
|
||||
group_ended: false,
|
||||
layer_state: None,
|
||||
styles: HashMap::new(),
|
||||
@@ -785,7 +788,16 @@ impl<'a> Reader<'a> {
|
||||
self.ensure_space();
|
||||
}
|
||||
let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) };
|
||||
self.frames.push(Frame { kind, children: Vec::new(), hidden, after: self.after_object, gs: self.gs.clone(), base, clips: Vec::new() });
|
||||
self.frames.push(Frame {
|
||||
kind,
|
||||
children: Vec::new(),
|
||||
hidden,
|
||||
after: self.after_object,
|
||||
gs: self.gs.clone(),
|
||||
base,
|
||||
clips: Vec::new(),
|
||||
compound_members: false,
|
||||
});
|
||||
self.after_object = false;
|
||||
Ok(())
|
||||
}
|
||||
@@ -836,7 +848,7 @@ impl<'a> Reader<'a> {
|
||||
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
|
||||
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
|
||||
};
|
||||
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f;
|
||||
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f;
|
||||
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
|
||||
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
|
||||
if matches!(kind, Kind::Group)
|
||||
@@ -879,6 +891,9 @@ impl<'a> Reader<'a> {
|
||||
if clipping && let Some(f) = self.frames.last_mut() {
|
||||
f.clips.push(f.children.len());
|
||||
}
|
||||
if compound_members {
|
||||
self.compound_groups.insert(node.id);
|
||||
}
|
||||
self.add(node, opened_hidden || hidden)?;
|
||||
if let Some(p) = painted {
|
||||
self.clip_fills.insert(p.id);
|
||||
@@ -994,6 +1009,16 @@ impl<'a> Reader<'a> {
|
||||
return Ok(());
|
||||
}
|
||||
";" => return self.end_obj(),
|
||||
"X=" => {
|
||||
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") {
|
||||
// `X=` also introduces legacy text frame art, read by `text_frames`.
|
||||
return Ok(());
|
||||
}
|
||||
self.end_path_quietly();
|
||||
return self.open(Kind::Mask, hidden);
|
||||
}
|
||||
"X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(),
|
||||
"X+" => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
// What follows an object (its name, transparency and style) ends with anything else. The
|
||||
@@ -1105,6 +1130,19 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
"d" => self.dash(&vals),
|
||||
"D" => {
|
||||
// The published format §6.8 permits `u` groups of compound members:
|
||||
// https://www.moon-soft.com/program/FORMAT/graphics/ai30.pdf
|
||||
// Their explicit winding declarations describe subpaths, not separate paints;
|
||||
// modern streams can also end these groups with `9 () XW`.
|
||||
let n = self.frames.len();
|
||||
if n >= 2
|
||||
&& self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound))
|
||||
&& let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group))
|
||||
{
|
||||
f.compound_members = true;
|
||||
}
|
||||
}
|
||||
// Transparency and state.
|
||||
"Xy" => self.transparency(&vals),
|
||||
// A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as
|
||||
@@ -1224,6 +1262,23 @@ impl<'a> Reader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Capture mask art in document coordinates, without adding it to the owner's siblings.
|
||||
fn end_mask(&mut self) -> Result<(), String> {
|
||||
self.end_path_quietly();
|
||||
let f = self.close("X+", |k| matches!(k, Kind::Mask))?;
|
||||
let (children, clip) = self.clip_first(f.children, &f.clips);
|
||||
let art = self.new_node(NodeKind::Group { children, clip }, &f.gs);
|
||||
self.gs = f.gs;
|
||||
self.after_object = f.after;
|
||||
let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else {
|
||||
return Err("mask art ends outside a mask dictionary".into());
|
||||
};
|
||||
if o.art.replace(Arc::new(art)).is_some() {
|
||||
return Err("a mask has more than one art block".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `;`: the innermost dictionary closes.
|
||||
fn end_obj(&mut self) -> Result<(), String> {
|
||||
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) {
|
||||
@@ -1379,13 +1434,19 @@ impl<'a> Reader<'a> {
|
||||
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the
|
||||
/// opacity mask it may carry.
|
||||
fn art_dictionary(&mut self, o: &Obj) {
|
||||
// A mask (`/Mask`, keyed `AI9_Mask`): its art is on `X=` … `X+` lines, and drawing the
|
||||
// object without it shows what the mask hides, so it must be named (#2004). A mask that
|
||||
// is disabled changes nothing.
|
||||
if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask"))
|
||||
&& mask.nums("Disabled").first().copied() != Some(1.0)
|
||||
{
|
||||
self.unreadable("opacity masks");
|
||||
if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) {
|
||||
if let Some(art) = &mask.art {
|
||||
let on = |key: &str| mask.nums(key).first().copied() == Some(1.0);
|
||||
let value =
|
||||
OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") };
|
||||
if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) {
|
||||
n.extras_mut().mask = Some(Box::new(value));
|
||||
} else if !on("Disabled") {
|
||||
self.unreadable("opacity masks without an owning object");
|
||||
}
|
||||
} else if mask.nums("Disabled").first().copied() != Some(1.0) {
|
||||
self.unreadable("opacity masks without readable art");
|
||||
}
|
||||
}
|
||||
let name = match o.text("AIArtName") {
|
||||
Some(name) => name,
|
||||
@@ -1542,7 +1603,11 @@ impl<'a> Reader<'a> {
|
||||
let style = vals.iter().rev().find_map(V::text).unwrap_or_default();
|
||||
let after = std::mem::take(&mut self.after_object);
|
||||
if code == 9.0
|
||||
&& let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. }))
|
||||
&& let Some(look) = self
|
||||
.frames
|
||||
.last()
|
||||
.and_then(|f| f.children.last())
|
||||
.filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id))
|
||||
{
|
||||
self.looks.insert(look.id);
|
||||
}
|
||||
|
||||
@@ -809,8 +809,7 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() {
|
||||
assert!(e.contains("pattern fills"), "{e}");
|
||||
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("symbols"), "{e}");
|
||||
// An opacity mask (#2004): drawing the layer's art whole would show what the mask hides, so
|
||||
// the mask is named and the file is read from its PDF part instead.
|
||||
// A mask with no owner must still be reported (#2004).
|
||||
let mask = "%_/ArtDictionary :\n%_/XMLUID : (M) ; (AI10_ArtUID) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\
|
||||
%_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n";
|
||||
let e = read(stream(&layer(mask)).as_bytes()).unwrap_err();
|
||||
@@ -1219,3 +1218,75 @@ fn a_gradient_mesh_reads_as_a_patch() {
|
||||
let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("gradient meshes"), "{e}");
|
||||
}
|
||||
|
||||
/// Both mesh-corner layouts must keep colour, position and handles, rather than guessing
|
||||
/// opacity from a coordinate. Unsupported weights and truncated corners remain errors.
|
||||
#[test]
|
||||
fn mesh_corners_with_and_without_opacity() {
|
||||
let geometry = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"];
|
||||
for (space, colour) in [("DeviceRGB", "0.2 0.4 0.6"), ("DeviceCMYK", "0.1 0.2 0.3 0.4"), ("DeviceGray", "0.5")] {
|
||||
let mesh = |opacity: &str| {
|
||||
let corners: String = geometry.iter().map(|g| format!("%_[{colour} {opacity}{g}] /N X#\n")).collect();
|
||||
format!("/Mesh X!\n%_/{space} /CS X#\n{corners}%_/E X#\n/End X!\n")
|
||||
};
|
||||
let old = art(&read_ok(&layer(&mesh(""))).doc);
|
||||
let new = art(&read_ok(&layer(&mesh("1 "))).doc);
|
||||
assert_eq!(old[0].kind, new[0].kind);
|
||||
let translucent = art(&read_ok(&layer(&mesh("0.25 "))).doc);
|
||||
let NodeKind::Mesh(m) = &translucent[0].kind else { panic!("mesh") };
|
||||
assert!(m.points.iter().all(|p| p.opacity == 0.25));
|
||||
assert_eq!(m.points[0].p, Point::new(10.0, 80.0));
|
||||
for broken in [mesh("").replace("10 25 1", "10 25 0.5"), mesh("").replace("15 20 1]", "15 20]")] {
|
||||
assert!(read(stream(&layer(&broken)).as_bytes()).unwrap_err().contains("gradient meshes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() {
|
||||
let owner = format!("{RED}{}f\n", rect_path(10.0, 20.0, 50.0, 60.0));
|
||||
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 60 L\n%_30 60 L\n%_30 20 L\n%_f\n%_U\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_; (AI9_Mask) ,\n%_;\n";
|
||||
let following = format!("{}f\n", rect_path(70.0, 20.0, 80.0, 30.0));
|
||||
for (invert, disabled, linked) in [(false, false, true), (true, false, false), (false, true, true)] {
|
||||
let mask = mask
|
||||
.replace("0 /Bool (Inverted)", &format!("{} /Bool (Inverted)", u8::from(invert)))
|
||||
.replace("0 /Bool (Disabled)", &format!("{} /Bool (Disabled)", u8::from(disabled)))
|
||||
.replace("1 /Bool (Linked)", &format!("{} /Bool (Linked)", u8::from(linked)));
|
||||
let r = read_ok(&layer(&format!("{owner}{mask}{following}")));
|
||||
assert!(r.warnings.is_empty());
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "mask colour must not leak");
|
||||
let m = a[0].extras().mask.as_ref().unwrap();
|
||||
assert_eq!((m.clip, m.invert, m.disabled, m.linked), (true, invert, disabled, linked));
|
||||
let mask_path = &m.art.children().unwrap()[0].children().unwrap()[0];
|
||||
assert!(matches!(mask_path.kind, NodeKind::Path { .. }));
|
||||
assert_eq!(mask_path.geometric_bounds(), Some(Rect::new(10.0, 40.0, 30.0, 80.0)));
|
||||
assert_ne!(mask_path.id, a[0].id);
|
||||
}
|
||||
for broken in [mask.replace("%_X+\n", ""), mask.replace("%_U\n", ""), mask.replace("%_f", "%_unknownMaskOperator")] {
|
||||
assert!(read(stream(&layer(&format!("{owner}{broken}"))).as_bytes()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// `9 () XW` also follows legacy compound member groups. The final gradient paints
|
||||
/// every member; the initial black paint is a placeholder, not a separate black object.
|
||||
#[test]
|
||||
fn winding_declared_compound_members_keep_the_final_gradient() {
|
||||
let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n0 0 0 0 1 0 0 2 50 0 Bs\n0 0 0 0 0 0 1 2 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let body = format!(
|
||||
"*u\nu\n1 D\n0 g\n{}f\n0 D\n{}Bb\n1 (G) 0 0 0 100 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n9 () XW\n*U\n",
|
||||
rect_path(10.0, 10.0, 80.0, 80.0),
|
||||
rect_path(30.0, 30.0, 50.0, 50.0)
|
||||
);
|
||||
let r = read_ok(&format!("{defs}{}", layer(&body)));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1);
|
||||
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("compound, not separately painted look") };
|
||||
assert_eq!(children.len(), 2);
|
||||
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.gradient.stops.len() == 2));
|
||||
assert!(children.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
// Without winding declarations this is still an independently painted appearance group.
|
||||
let r = read_ok(&format!("{defs}{}", layer(&body.replace("1 D\n", "").replace("0 D\n", ""))));
|
||||
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }));
|
||||
}
|
||||
|
||||
@@ -703,6 +703,44 @@ fn compare_scale(r: Rect) -> f64 {
|
||||
(400.0 / r.width().max(r.height()).max(1.0)).min(4.0)
|
||||
}
|
||||
|
||||
/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases
|
||||
/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher
|
||||
/// resolution and apply the same reconstruction filter; keep the existing difference limits.
|
||||
fn comparison_samples(doc: &Document) -> u32 {
|
||||
let (mut images, mut vector) = (false, false);
|
||||
doc.walk(|n| match n.kind {
|
||||
NodeKind::Image(_) => images = true,
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {}
|
||||
_ => vector = true,
|
||||
});
|
||||
if images && !vector { 4 } else { 1 }
|
||||
}
|
||||
|
||||
/// Render for the page check. The comparison scale bounds the long side to 400 px, so the
|
||||
/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha.
|
||||
fn comparison_render(
|
||||
renderer: &mut vectorcraft_render::Renderer,
|
||||
doc: &Document,
|
||||
rect: Rect,
|
||||
scale: f64,
|
||||
samples: u32,
|
||||
) -> vectorcraft_render::Rendered {
|
||||
let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false);
|
||||
if samples == 1 {
|
||||
return rendered;
|
||||
}
|
||||
let (width, height) = match vectorcraft_render::raster_size(rect, scale) {
|
||||
Ok(size) => size,
|
||||
// A malformed region cannot provide a comparable raster.
|
||||
Err(_) => return rendered,
|
||||
};
|
||||
let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else {
|
||||
return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() };
|
||||
};
|
||||
let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw();
|
||||
vectorcraft_render::Rendered { width, height, pixels }
|
||||
}
|
||||
|
||||
/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers
|
||||
/// with a renderer of their own, which keeps their decoded images between draws (the page's images
|
||||
/// can share their keys and not their pixels, so the two don't share one).
|
||||
@@ -712,6 +750,7 @@ struct PageView {
|
||||
page_type: Vec<Rect>,
|
||||
layers: vectorcraft_render::Renderer,
|
||||
scale: f64,
|
||||
samples: u32,
|
||||
}
|
||||
|
||||
impl PageView {
|
||||
@@ -720,10 +759,11 @@ impl PageView {
|
||||
let scale = compare_scale(ra);
|
||||
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
|
||||
// the app that wrote the file.
|
||||
let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false);
|
||||
let samples = comparison_samples(frame.page);
|
||||
let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples);
|
||||
let mut page_type = vec![];
|
||||
type_areas(&frame.page.layers, ra, scale, &mut page_type);
|
||||
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale }
|
||||
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples }
|
||||
}
|
||||
|
||||
/// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]).
|
||||
@@ -731,7 +771,7 @@ impl PageView {
|
||||
// The page doesn't print what isn't printed.
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let y = self.layers.render_region(b, rb, self.scale, false);
|
||||
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
|
||||
let mut areas = self.page_type.clone();
|
||||
type_areas(&b.layers, rb, self.scale, &mut areas);
|
||||
compared(&self.page, &y, &areas)
|
||||
@@ -743,7 +783,7 @@ impl PageView {
|
||||
fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 {
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let y = self.layers.render_region(b, rb, self.scale, false);
|
||||
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
|
||||
if (y.width, y.height) != (self.page.width, self.page.height) {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -759,8 +799,9 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let scale = compare_scale(ra);
|
||||
// A renderer each: the two documents' images can share their keys and not their pixels.
|
||||
let x = vectorcraft_render::Renderer::new().render_region(a, ra, scale, false);
|
||||
let y = vectorcraft_render::Renderer::new().render_region(b, rb, scale, false);
|
||||
let samples = comparison_samples(a);
|
||||
let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples);
|
||||
let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples);
|
||||
let mut areas = vec![];
|
||||
type_areas(&a.layers, ra, scale, &mut areas);
|
||||
type_areas(&b.layers, rb, scale, &mut areas);
|
||||
@@ -772,7 +813,7 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
|
||||
/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents,
|
||||
/// and what the comparison looks for is art that is missing or out of place.
|
||||
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference {
|
||||
if x.width != y.width || x.height != y.height {
|
||||
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
|
||||
return Difference { any: 1.0, solid: 1.0 };
|
||||
}
|
||||
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
|
||||
@@ -1358,4 +1399,58 @@ mod tests {
|
||||
let texts: Vec<String> = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["Hello", "abcd"]);
|
||||
}
|
||||
/// A flattened page samples a fine stripe pattern once per comparison pixel; that
|
||||
/// aliases to white although vector coverage is gray. Shared minification avoids the
|
||||
/// false fallback, while genuinely missing art must still be rejected.
|
||||
#[test]
|
||||
fn raster_page_comparison_filters_minification_without_accepting_missing_art() {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject};
|
||||
let mut vector = Document::new(1000.0, 500.0);
|
||||
let layer = vector.layers[0].id;
|
||||
for i in 0..400 {
|
||||
let x = f64::from(i) * 2.5 + 0.5;
|
||||
let n = Node::path(
|
||||
vector.alloc_id(),
|
||||
vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 1.0, 500.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
vector.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let rect = vector.artboards[0].rect;
|
||||
let source = vectorcraft_render::Renderer::new().render_region(&vector, rect, 1.6, true);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_raw(source.width, source.height, source.to_straight())
|
||||
.unwrap()
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
let mut page = Document::new(1000.0, 500.0);
|
||||
page.images.insert(key.clone(), blob);
|
||||
let image = Node::new(
|
||||
page.alloc_id(),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key,
|
||||
width: source.width,
|
||||
height: source.height,
|
||||
xf: Affine::scale(1.0 / 1.6),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap();
|
||||
assert_eq!(comparison_samples(&page), 4);
|
||||
assert_eq!(comparison_samples(&vector), 1, "ordinary vector pages retain their comparison");
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let a = comparison_render(&mut renderer, &page, rect, 0.4, 1);
|
||||
let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1);
|
||||
assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
|
||||
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
|
||||
let mut view = PageView::new(&frame);
|
||||
assert!(compare(&frame, &mut view, &vector).is_ok());
|
||||
assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0)).is_err(), "missing artwork remains an error");
|
||||
let shifted = Rect::new(0.0, 0.0, 500.0, 500.0);
|
||||
assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,3 +994,51 @@ fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() {
|
||||
let b = frame.bounds().unwrap();
|
||||
assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// A raster print page can carry a fully vector editing copy. The legacy mesh has no
|
||||
/// opacity operand and its vector mask hides half of it: ignoring either loses this import.
|
||||
#[test]
|
||||
fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() {
|
||||
let mesh = "/Mesh X!\n%_/DeviceRGB /CS X#\n\
|
||||
%_[0 0 1 20 20 20 30 1 30 20 1] /N X#\n\
|
||||
%_[0 0 1 20 60 30 60 1 20 50 1] /N X#\n\
|
||||
%_[0 0 1 60 60 60 50 1 50 60 1] /N X#\n\
|
||||
%_[0 0 1 60 20 50 20 1 60 30 1] /N X#\n%_/E X#\n/End X!\n";
|
||||
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_1 g\n%_20 20 m\n%_20 60 L\n%_40 60 L\n%_40 20 L\n%_f\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n";
|
||||
let curve = "0 1 1 0 1 0 0 Xa\n70 20 m\n70 50 90 50 90 20 c\nf\n";
|
||||
let data = native(&layer("Editable", true, &format!("u\n{mesh}{mask}{curve}U\n")));
|
||||
let samples = ("0000ff".repeat(20) + &"ffffff".repeat(20)).repeat(40);
|
||||
let page = format!(
|
||||
"gsave 20 20 translate 40 40 scale /DeviceRGB setcolorspace \
|
||||
<< /ImageType 1 /Width 40 /Height 40 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] \
|
||||
/ImageMatrix [40 0 0 -40 0 40] /DataSource <{samples}> >> image grestore \
|
||||
1 0 0 setrgbcolor 70 20 moveto 70 50 90 50 90 20 curveto closepath fill"
|
||||
);
|
||||
// Use AI9's zlib container, too, rather than only the newer Zstandard test helper.
|
||||
use std::io::Write as _;
|
||||
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(data.as_bytes()).unwrap();
|
||||
let packed = ascii85(&encoder.finish().unwrap());
|
||||
let file = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n%{packed}\n%AI9_PrivateDataEnd\n"
|
||||
);
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert_eq!(names(&r.document), ["Editable"]);
|
||||
let group = &r.document.layers[0].children().unwrap()[0];
|
||||
let a = group.children().unwrap();
|
||||
assert_eq!(a.len(), 2);
|
||||
assert!(matches!(a[0].kind, NodeKind::Mesh(_)));
|
||||
assert!(a[0].extras().mask.is_some());
|
||||
let NodeKind::Path { path, .. } = &a[1].kind else { panic!("editable path") };
|
||||
assert_eq!(path.anchor_count(), 2);
|
||||
assert_ne!(path.subpaths[0].anchors[0].h_out, path.subpaths[0].anchors[0].p);
|
||||
assert_ne!(a[0].id, a[1].id);
|
||||
// The normal EPS path remains available explicitly.
|
||||
let page = crate::import_with(file.as_bytes(), false).unwrap();
|
||||
assert!(matches!(page.document.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)));
|
||||
// A wrong editing copy must still fail the page comparison.
|
||||
let wrong = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 120 100");
|
||||
let r = import(wrong.as_bytes()).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-1
File diff suppressed because one or more lines are too long
+4
-3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user