EPS: preserve editable Illustrator art and filter raster page comparison

This commit is contained in:
Roman Vasilyev
2026-10-17 13:48:05 +07:00
committed by bflatastic
parent 26644850fc
commit 4ed644d98d
11 changed files with 347 additions and 31 deletions
+3
View File
@@ -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
+18 -2
View File
@@ -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;
}
+2
View File
@@ -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 {
+78 -13
View File
@@ -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);
}
+73 -2
View File
@@ -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 { .. }));
}
+102 -7
View File
@@ -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");
}
}
+48
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long