Merge pull request #2571 from theromis/fix/eps-editable-illustrator-import

Fix Illustrator EPS import to preserve editable vector artwork
This commit is contained in:
BFlat
2026-10-17 14:31:09 +07:00
committed by GitHub
30 changed files with 382 additions and 34 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
+1 -1
View File
@@ -459,7 +459,7 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo
if let Some(k) = &it.copies
&& Arc::ptr_eq(&k.from, &it.doc)
&& k.roots == roots
&& (!k.selection.guides.is_empty() == has_guide_copies)
&& (k.selection.guides.is_empty() != has_guide_copies)
{
return Ok(Some(k.clone()));
}
+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 Adobe Illustrator File Format Specification (version 3.0), §6.8,
// permits `u` groups of compound members.
// 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);
}
+1 -1
View File
@@ -879,7 +879,7 @@ impl Renderer {
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
if !self.shadows.get(&old).is_some_and(|e| silhouette(&pm, colored) == e.made.pixels) {
if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) {
return;
}
if let Some(mut e) = self.shadows.remove(&old) {
+1 -1
View File
@@ -270,7 +270,7 @@ fn variants(art: &Art, n: usize, soft: bool, settings: &[Setting]) -> Vec<String
for &(name, set) in settings {
let mut params = params.clone();
set(&mut params);
let expect = (!soft && !(tiny && name == LOOSEST.0)).then_some(counts.as_slice());
let expect = (!(soft || tiny && name == LOOSEST.0)).then_some(counts.as_slice());
let (line, w) = run(art, soft, bw, &params, name, expect);
println!("{line}");
wrong.extend(w);
+2
View File
@@ -4999,3 +4999,5 @@ mark-style Japanese Japonské
Movie Clip Filmový klip
@msg name is empty název je prázdný
@msg no symbol named `{n}` žádný symbol s názvem `{n}`
@msg a mask has more than one art block maska má více než jeden blok kresby
@msg mask art ends outside a mask dictionary kresba masky končí mimo slovník masky
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4536,3 +4536,5 @@ theme Appearance Erscheinungsbild
Movie Clip Movieclip
@msg name is empty Name ist leer
@msg no symbol named `{n}` kein Symbol namens `{n}`
@msg a mask has more than one art block eine Maske enthält mehr als einen Grafikblock
@msg mask art ends outside a mask dictionary die Maskengrafik endet außerhalb eines Maskenwörterbuchs
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4951,3 +4951,5 @@ effect Points Σημεία
Movie Clip Κλιπ ταινίας
@msg name is empty το όνομα είναι κενό
@msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}`
@msg a mask has more than one art block η μάσκα έχει περισσότερα από ένα μπλοκ γραφικών
@msg mask art ends outside a mask dictionary τα γραφικά της μάσκας τελειώνουν έξω από το λεξικό της μάσκας
Can't render this file because it contains an unexpected character in line 1897 and column 19.
+2
View File
@@ -4941,3 +4941,5 @@ mark-style Japanese Japonesas
Movie Clip Clip de película
@msg name is empty el nombre está vacío
@msg no symbol named `{n}` no hay ningún símbolo llamado `{n}`
@msg a mask has more than one art block una máscara tiene más de un bloque de ilustración
@msg mask art ends outside a mask dictionary la ilustración de la máscara termina fuera de un diccionario de máscara
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4963,3 +4963,5 @@ mark-style Japanese Japanilainen
Movie Clip Elokuvaleike
@msg name is empty nimi on tyhjä
@msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole
@msg a mask has more than one art block maskissa on useampi kuin yksi grafiikkalohko
@msg mask art ends outside a mask dictionary maskin grafiikka päättyy maskin sanakirjan ulkopuolelle
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4998,3 +4998,5 @@ theme Appearance Apparence
Movie Clip Clip
@msg name is empty le nom est vide
@msg no symbol named `{n}` aucun symbole nommé `{n}`
@msg a mask has more than one art block un masque contient plusieurs blocs graphiques
@msg mask art ends outside a mask dictionary les éléments graphiques du masque se terminent en dehors d’un dictionnaire de masque
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4966,3 +4966,5 @@ imageTrace Outline Körvonal
Movie Clip Mozgóklip
@msg name is empty a név üres
@msg no symbol named `{n}` nincs `{n}` nevű szimbólum
@msg a mask has more than one art block egy maszk egynél több grafikai blokkot tartalmaz
@msg mask art ends outside a mask dictionary a maszk grafikája a maszk szótárán kívül ér véget
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4576,3 +4576,5 @@ lighting Light Luce
Movie Clip Clip filmato
@msg name is empty il nome è vuoto
@msg no symbol named `{n}` nessun simbolo denominato `{n}`
@msg a mask has more than one art block una maschera contiene più di un blocco grafico
@msg mask art ends outside a mask dictionary la grafica della maschera termina fuori da un dizionario di maschera
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4617,3 +4617,5 @@ lighting Light ライト
Movie Clip ムービークリップ
@msg name is empty 名前が空です
@msg no symbol named `{n}` `{n}` という名前のシンボルはありません
@msg a mask has more than one art block マスクに複数のアートブロックがあります
@msg mask art ends outside a mask dictionary マスクのアートがマスク辞書の外で終わっています
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4540,3 +4540,5 @@ lighting Light 광원
Movie Clip 무비 클립
@msg name is empty 이름이 비어 있습니다
@msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다
@msg a mask has more than one art block 마스크에 아트 블록이 두 개 이상 있습니다
@msg mask art ends outside a mask dictionary 마스크 아트가 마스크 사전 밖에서 끝납니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4844,3 +4844,5 @@ mark-style Japanese Japońskie
Movie Clip Klip filmowy
@msg name is empty nazwa jest pusta
@msg no symbol named `{n}` brak symbolu o nazwie `{n}`
@msg a mask has more than one art block maska ma więcej niż jeden blok grafiki
@msg mask art ends outside a mask dictionary grafika maski kończy się poza słownikiem maski
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4916,3 +4916,5 @@ mark-style Japanese Japonesas
# Symbol Options › Export Type
@msg name is empty o nome está vazio
@msg no symbol named `{n}` nenhum símbolo chamado `{n}`
@msg a mask has more than one art block uma máscara contém mais de um bloco de arte
@msg mask art ends outside a mask dictionary a arte da máscara termina fora de um dicionário de máscara
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4572,3 +4572,5 @@ lighting Light Свет
Movie Clip Видеоклип
@msg name is empty имя пустое
@msg no symbol named `{n}` нет символа с именем `{n}`
@msg a mask has more than one art block маска содержит более одного блока графики
@msg mask art ends outside a mask dictionary графика маски заканчивается за пределами словаря маски
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4955,3 +4955,5 @@ theme Appearance Utseende
Movie Clip Filmklipp
@msg name is empty namnet är tomt
@msg no symbol named `{n}` ingen symbol med namnet `{n}`
@msg a mask has more than one art block en mask innehåller mer än ett grafikblock
@msg mask art ends outside a mask dictionary maskens grafik slutar utanför en maskordlista
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -4556,3 +4556,5 @@ lighting Light Світло
Movie Clip Кліп
@msg name is empty ім’я порожнє
@msg no symbol named `{n}` немає символу з назвою `{n}`
@msg a mask has more than one art block маска містить більше одного блоку графіки
@msg mask art ends outside a mask dictionary графіка маски закінчується за межами словника маски
Can't render this file because it contains an unexpected character in line 720 and column 40.
+2
View File
@@ -4556,3 +4556,5 @@ theme Appearance Giao diện
Movie Clip Clip phim
@msg name is empty tên trống
@msg no symbol named `{n}` không có biểu tượng nào tên `{n}`
@msg a mask has more than one art block mặt nạ có nhiều hơn một khối đồ họa
@msg mask art ends outside a mask dictionary đồ họa mặt nạ kết thúc bên ngoài từ điển mặt nạ
Can't render this file because it contains an unexpected character in line 3773 and column 10.
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