From 18b9c6113e4c8c25479d32d6b755c597655c04ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 09:24:23 +0700 Subject: [PATCH] .ai import: one object the reader can't read comes in as the page's art, and the layers stay Squashed from #5050 onto main. --- .../engine/src/cmd/fileio/tests_aiimport.rs | 22 + crates/eps/src/import/ai.rs | 2 +- crates/eps/src/import/ai/obj.rs | 2 + crates/eps/src/import/ai/read.rs | 20 +- crates/eps/src/import/ai/read/art.rs | 68 +- crates/eps/src/import/ai/read/containers.rs | 156 +++- crates/eps/src/import/ai/read/directives.rs | 76 +- crates/eps/src/import/ai/read/document.rs | 16 +- crates/eps/src/import/ai/read/ops.rs | 26 +- crates/eps/src/import/ai/read/types.rs | 8 + crates/eps/src/import/ai/tests.rs | 288 ++++++- crates/eps/src/import/native.rs | 4 +- crates/eps/src/import/native/build.rs | 754 +++++++++++++++++- crates/eps/src/import/native/slots.rs | 1 + crates/eps/src/import/tests_native.rs | 720 +++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 43 files changed, 2190 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index edccacd50..6f603b200 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -82,6 +82,28 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() { assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); } +#[test] +fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() { + let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0)); + let unread = format!("u\n0 0 1 0 k\n{}f\nZZ\nU\n{}", ai::rect(50.0, 10.0, 54.0, 14.0), ai::named("Unread")); + let data = ai::editing_data(200.0, 100.0, &(ai::layer("Live", &live) + &ai::layer("Mixed", &unread))); + let page = "0 0 1 0 k 10 10 4 4 re f 1 0 0 0 k 50 10 4 4 re f"; + let mut session = Session::new(); + let result = open(&mut session, "unread.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap(); + let doc = &session.doc().unwrap().doc; + assert_eq!(names(&doc.layers), ["Live", "Mixed"], "{result}"); + assert!(matches!(doc.layers[0].children().unwrap()[0].kind, NodeKind::Path { .. }), "{result}"); + assert!(matches!(doc.layers[1].children().unwrap()[0].kind, NodeKind::Group { .. }), "{result}"); + assert_eq!(doc.layers[1].children().unwrap()[0].name.as_deref(), Some("Unread"), "{result}"); + let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0]; + assert!( + matches!(replacement.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill)) + if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))), + "{result}" + ); + assert!(result["warnings"].to_string().contains("`ZZ` (1)"), "{result}"); +} + /// A palette of three swatches and a color group `Inks` holding a fourth. const INKS_PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\ 0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n"; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 5e60e7e81..50fcec241 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -34,7 +34,7 @@ use crate::ps; pub(crate) use frames::text_frames; pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables}; -pub(crate) use read::{slot_frame, slot_of}; +pub(crate) use read::{join_and, slot_frame, slot_of}; #[cfg(test)] mod tests; diff --git a/crates/eps/src/import/ai/obj.rs b/crates/eps/src/import/ai/obj.rs index d91f153a8..10027f010 100644 --- a/crates/eps/src/import/ai/obj.rs +++ b/crates/eps/src/import/ai/obj.rs @@ -68,6 +68,8 @@ pub(super) struct Obj { pub content: Vec, /// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary. pub art: Option>, + /// What that art has that isn't read, reported once the mask is known to be enabled. + pub unread: Vec, } impl Obj { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5cf477d75..8e7753dd7 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -73,6 +73,7 @@ mod document; mod ops; mod types; +pub(crate) use art::join_and; use art::preview_png; pub use document::{PaletteSwatches, VariableLibrary, artboards, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; @@ -101,6 +102,10 @@ pub struct Structure { pub template: Option<[f64; 4]>, /// What layers that don't show have that isn't read (operators, objects): left out of them. pub hidden_unread: BTreeSet, + /// Top-level object ids and the unread kinds within them. + pub unread: Vec<(NodeId, Vec)>, + /// Other bounds for previewless placed files whose page art uses the file box's corner. + pub unread_elsewhere: Vec<(NodeId, Rect)>, /// The names of text objects, by their slot's id. pub slot_names: HashMap, /// Where the drawn looks lay that objects read with their art style took the place of (the @@ -162,6 +167,12 @@ struct Reader<'a> { unknown: BTreeSet, /// What layers that don't show have that this reader doesn't read. hidden_unread: BTreeSet, + unread: Vec<(NodeId, Vec)>, + symbol_bounds: HashMap, + /// Depth shared by pattern and symbol readers. + subreader_depth: usize, + pending_unread_fill: bool, + pending_unread_stroke: bool, /// The header's bounding boxes and template box. bbox: Option<[f64; 4]>, hires: Option<[f64; 4]>, @@ -209,6 +220,7 @@ struct Reader<'a> { restyled: Vec, /// See [`Structure::placed_elsewhere`]. placed_elsewhere: Vec<(NodeId, Affine)>, + unread_elsewhere: Vec<(NodeId, Rect)>, /// CMYK samples converted, by value. cmyk_cache: HashMap<[u8; 4], [u8; 3]>, /// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes. @@ -277,6 +289,11 @@ impl<'a> Reader<'a> { unsupported: BTreeSet::new(), unknown: BTreeSet::new(), hidden_unread: BTreeSet::new(), + unread: Vec::new(), + symbol_bounds: HashMap::new(), + subreader_depth: 0, + pending_unread_fill: false, + pending_unread_stroke: false, bbox: None, hires: None, template: None, @@ -301,6 +318,7 @@ impl<'a> Reader<'a> { palette: super::palette::Palette::default(), restyled: vec![], placed_elsewhere: vec![], + unread_elsewhere: vec![], mesh: None, cmyk_cache: HashMap::new(), done: false, @@ -332,7 +350,7 @@ impl<'a> Reader<'a> { let w = std::str::from_utf8(w).unwrap_or("?"); self.op(w, hidden)?; } - Tok::Comment(c) => self.comment(c)?, + Tok::Comment(c) => self.comment(c, hidden)?, Tok::Data(d) => self.image(d)?, Tok::Preview(d) => { if let Some(p) = self.place.as_mut() { diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 89df83cd4..3f60ebe11 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -12,7 +12,7 @@ impl<'a> Reader<'a> { /// A text object (`/AI11Text`): an empty group standing for it, named after its story. pub(super) fn text_slot(&mut self, o: &Obj) -> Result<(), String> { if self.pattern.is_some() { - self.unreadable("type in a pattern"); + self.unreadable("type in a pattern", false); } // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its @@ -171,6 +171,8 @@ impl<'a> Reader<'a> { "b" => (true, true), _ => (false, false), }; + // A gradient instance's paint replaces the missing pattern. + let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none()); let mut items = Vec::new(); if fill && !clip { items.push(AppearanceItem::Fill(FillLayer { @@ -212,7 +214,28 @@ impl<'a> Reader<'a> { .into(); copy }); + let unread_bounds = unread.then(|| node.visual_bounds()).flatten(); + let unread_id = node.id; self.add(node, hidden)?; + if unread && self.pattern.is_some() { + // Art in a pattern's definition painted with a pattern it doesn't have: the tile can't be read. + self.unsupported.insert("pattern fills".into()); + } else if unread && ((hidden && !self.in_mask()) || gs.hidden || !self.shown()) { + self.hidden_unread.insert("pattern fills".into()); + } else if unread && self.in_mask() { + self.unreadable("pattern fills", false); + } else if unread { + match unread_bounds { + Some(b) if sane(b) && b.area() > 0.0 => { + if !self.mark_unread("pattern fills".into()) { + self.unread.push((unread_id, vec!["pattern fills".into()])); + } + } + _ => { + self.unsupported.insert("pattern fills".into()); + } + } + } if let Some(copy) = copy { self.clip_fills.insert(copy.id); self.add(copy, hidden)?; @@ -224,7 +247,7 @@ impl<'a> Reader<'a> { pub(super) fn end_mesh(&mut self) -> Result<(), String> { let Some((m, hidden)) = self.mesh.take() else { return Ok(()) }; if m.unread || m.patches.is_empty() { - self.unreadable("gradient meshes it can't read"); + self.unreadable("gradient meshes it can't read", hidden && self.pattern.is_none()); return Ok(()); } let gs = self.gs.clone(); @@ -461,10 +484,39 @@ impl<'a> Reader<'a> { /// shows the preview the data keeps of it, as a linked image whose file is missing does. One /// without a preview, a path or a matrix isn't read. pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> { - let Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) = self.place.take().filter(|p| !p.path.is_empty()) - else { - self.unreadable("placed files"); - return Ok(()); + let (m, b, path, png, w, h) = match self.place.take() { + Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) if !path.is_empty() => (m, b, path, png, w, h), + other => { + let boxed = other.as_ref().and_then(|p| Some((p.matrix?, p.bounds?))).filter(|(_, b)| sane(*b) && b.area() > 0.0); + let mut have_bounds = false; + if self.shown() + && !self.gs.hidden + && !hidden + && let Some((m, b)) = boxed + { + let centre = self.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + let origin = centre.map_or(b.origin(), |(x, y)| Point::new(CANVAS_CENTRE - x, CANVAS_CENTRE + y)); + let box_at_canvas = Rect::new(origin.x, origin.y, origin.x + b.width(), origin.y + b.height()); + let bounds = (self.space() * m).transform_rect_bbox(box_at_canvas); + have_bounds = sane(bounds) && bounds.area() > 0.0; + if have_bounds { + let placeholder_id = self.unread_box("placed files", bounds, hidden)?; + if let Some(placeholder_id) = placeholder_id.filter(|_| self.unread_elsewhere.len() < MAX_PLACED_ELSEWHERE) { + let corner = (self.space() * m).transform_rect_bbox(b); + if sane(corner) && corner.area() > 0.0 && corner != bounds { + self.unread_elsewhere.push((placeholder_id, corner)); + } + } + return Ok(()); + } + } + if self.shown() && !self.gs.hidden && !hidden && !have_bounds { + self.unsupported.insert("placed files".into()); + } else { + self.unreadable("placed files", hidden); + } + return Ok(()); + } }; // The file's pixels (y down) start at the canvas point of art space's origin (the box's own // corner when the file doesn't say where the canvas is); the preview's are spread over them. @@ -481,7 +533,7 @@ impl<'a> Reader<'a> { .filter(|a| a.as_coeffs().iter().all(|v| v.is_finite())); let xf = self.space() * m * over_file; if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.unreadable("placed files"); + self.unreadable("placed files", hidden); return Ok(()); } // The preview is all the document has of the file: what a missing link shows. @@ -581,7 +633,7 @@ fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { } /// `a`, `a and b`, `a, b and c`. -pub(super) fn join_and(items: &[&str]) -> String { +pub(crate) fn join_and(items: &[&str]) -> String { match items { [] => String::new(), [one] => (*one).to_string(), diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 992a9c564..39f398a2e 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -21,9 +21,12 @@ impl<'a> Reader<'a> { after: self.after_object, mesh_ended: self.mesh_ended, gs: self.gs.clone(), + pending_unread_fill: self.pending_unread_fill, + pending_unread_stroke: self.pending_unread_stroke, base, clips: Vec::new(), compound_members: false, + unread: Vec::new(), state: None, }); self.after_object = false; @@ -76,10 +79,27 @@ 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, compound_members, .. } = f; + let Frame { + kind, + children: kids, + gs, + clips, + hidden: opened_hidden, + compound_members, + unread, + pending_unread_fill, + pending_unread_stroke, + .. + } = f; + if matches!(kind, Kind::Clip) { + self.gs = gs.clone(); + self.pending_unread_fill = pending_unread_fill; + self.pending_unread_stroke = pending_unread_stroke; + } // A group holding only clipping paths (`q … u 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) + && unread.is_empty() && !clips.is_empty() && clips.len() == kids.len() && matches!(self.frames.last(), Some(Frame { kind: Kind::Clip, .. })) @@ -109,7 +129,14 @@ impl<'a> Reader<'a> { // painted copies make a compound of their own, under what it clips. let (fills, kids): (Vec, Vec) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id)); painted = self.compound(fills, &gs); - let Some(n) = self.compound(kids, &gs) else { return Ok(()) }; + let Some(n) = self.compound(kids, &gs) else { + if gs.hidden || !self.shown() { + self.hidden_unread.extend(unread); + } else { + self.unsupported.extend(unread); + } + return Ok(()); + }; n } // A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group. @@ -124,6 +151,13 @@ impl<'a> Reader<'a> { if compound_members { self.compound_groups.insert(node.id); } + if !unread.is_empty() { + if !node.visible || !self.shown() { + self.hidden_unread.extend(unread); + } else { + self.unread.push((node.id, unread)); + } + } self.add(node, opened_hidden || hidden)?; if let Some(p) = painted { self.clip_fills.insert(p.id); @@ -173,6 +207,8 @@ impl<'a> Reader<'a> { } pub(super) fn end_layer(&mut self) -> Result<(), String> { + self.pending_unread_fill = false; + self.pending_unread_stroke = false; let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; let state = self.layer_state.take(); // An `Xy` right after the layer's last mesh or blend, then `LB`: the layer's, not theirs. @@ -208,15 +244,16 @@ impl<'a> Reader<'a> { Ok(()) } - /// An operator the reader doesn't know: on hidden art it is only noted, on shown art the file - /// is read from its PDF part. - pub(super) fn note_unknown(&mut self, w: &str) { + /// An operator the reader doesn't know is scoped to its top-level object when possible. + pub(super) fn note_unknown(&mut self, w: &str, hidden: bool) { let op: String = w.chars().take(24).collect(); - if !self.shown() { + // A pattern definition or an owning mask can be on `%_` lines while still + // determining visible art. Unknown operators there must remain unread. + if (self.gs.hidden && self.pattern.is_none()) || (hidden && self.pattern.is_none() && !self.in_mask()) || !self.shown() { if self.hidden_unread.len() < 16 { self.hidden_unread.insert(format!("`{op}`")); } - } else if self.unknown.len() < 16 { + } else if !self.defer_to_mask(&format!("`{op}`")) && !self.mark_unread(format!("`{op}`")) && self.unknown.len() < 16 { self.unknown.insert(op); } } @@ -234,6 +271,8 @@ impl<'a> Reader<'a> { self.open(Kind::Obj(Box::new(Obj { ty, ..Obj::default() })), hidden)?; // A dictionary's state starts afresh (styles set theirs inside). self.gs = GState::default(); + self.pending_unread_fill = false; + self.pending_unread_stroke = false; Ok(()) } @@ -244,6 +283,8 @@ impl<'a> Reader<'a> { 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.pending_unread_fill = f.pending_unread_fill; + self.pending_unread_stroke = f.pending_unread_stroke; self.after_object = f.after; self.mesh_ended = f.mesh_ended; let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { @@ -252,6 +293,7 @@ impl<'a> Reader<'a> { if o.art.replace(Arc::new(art)).is_some() { return Err("a mask has more than one art block".into()); } + o.unread = f.unread; Ok(()) } @@ -261,13 +303,15 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, mesh_ended, state, .. } = f; + let Frame { kind, children, base, hidden, gs, pending_unread_fill, pending_unread_stroke, after, mesh_ended, state, .. } = f; let Kind::Obj(mut o) = kind else { return Ok(()) }; obj::close(&mut o, &mut self.stack, base); self.obj_hidden = hidden; // Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what // follows the object the dictionary is about: a mesh's state comes after its mask. self.gs = gs; + self.pending_unread_fill = pending_unread_fill; + self.pending_unread_stroke = pending_unread_stroke; if let Some(state) = state { self.keep_state(state); } @@ -295,7 +339,7 @@ impl<'a> Reader<'a> { } "Document" => self.document(&o), "AI11Text" => self.text_slot(&o)?, - "SymbolInstance" => self.unreadable("symbols"), + "SymbolInstance" => self.symbol_instance(&o)?, "ForeignObject" => self.foreign_art(&o)?, _ => {} } @@ -330,14 +374,95 @@ impl<'a> Reader<'a> { /// Note something the data has that isn't read: an error on a layer that shows, left out of a /// layer that doesn't. - pub(super) fn unreadable(&mut self, what: &str) { - if self.shown() { - self.unsupported.insert(what.into()); + pub(super) fn unreadable(&mut self, what: &str, hidden: bool) { + if (!hidden || self.in_mask()) && !self.gs.hidden && self.shown() { + if !self.defer_to_mask(what) && !self.mark_unread(what.to_string()) { + self.unsupported.insert(what.into()); + } } else { self.hidden_unread.insert(what.into()); } } + /// Keep what an opacity mask's art doesn't read with the mask, until its dictionary says + /// whether the mask is enabled. Whether art of a mask is being read. Operators it doesn't know + /// are kept up to 16, as [`Reader::note_unknown`] keeps them. + fn defer_to_mask(&mut self, what: &str) -> bool { + let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Mask)) else { return false }; + let full = what.starts_with('`') && f.unread.iter().filter(|w| w.starts_with('`')).count() >= 16; + if !full && !f.unread.iter().any(|w| w == what) { + f.unread.push(what.into()); + } + true + } + + /// Mark the first object container below the innermost layer. + pub(super) fn mark_unread(&mut self, what: String) -> bool { + let Some(layer) = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))) else { return false }; + let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)) else { + return false; + }; + if !frame.unread.contains(&what) { + frame.unread.push(what); + } + true + } + + /// Stand in for one unread object with a box that the page can match. + pub(super) fn unread_box(&mut self, what: &str, bounds: super::Rect, hidden: bool) -> Result, String> { + if (hidden && !self.in_mask()) || self.gs.hidden || !self.shown() { + self.hidden_unread.insert(what.into()); + return Ok(None); + } + // A mask's art isn't an object of the layers: its object, or the file, is what isn't read. + if self.in_mask() { + self.unreadable(what, false); + return Ok(None); + } + let in_container = self + .frames + .iter() + .rposition(|f| matches!(f.kind, Kind::Layer(_))) + .is_some_and(|layer| self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip))); + if !super::art::sane(bounds) || bounds.area() <= 0.0 { + self.unsupported.insert(what.into()); + return Ok(None); + } + let path = vectorcraft_geom::shapes::rectangle(bounds); + let gs = self.gs.clone(); + let node = self.new_node(NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }, &gs); + let id = node.id; + if in_container { + self.mark_unread(what.into()); + } + self.add(node, hidden)?; + if !in_container { + self.unread.push((id, vec![what.into()])); + } + Ok(Some(id)) + } + + fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> { + let bounds = o.get("SymbolRef").and_then(|v| v.iter().find_map(V::text)).and_then(|name| self.symbol_bounds.get(&name).copied()); + let matrix = o + .get("Matrix") + .or_else(|| o.get("Transform")) + .map(|v| v.iter().find_map(|item| if let V::Arr(a) = item { Some(Self::nums(a)) } else { None }).unwrap_or_else(|| Self::nums(v))); + let box_at_canvas = match (bounds, matrix.as_deref()) { + (Some(b), Some([a, c, d, e, x, y])) if [*a, *c, *d, *e, *x, *y].iter().all(|n| n.is_finite()) => { + Some(self.space().transform_rect_bbox(super::Affine::new([*a, *c, *d, *e, *x, *y]).transform_rect_bbox(b))) + } + _ => None, + }; + match box_at_canvas { + Some(b) => self.unread_box("symbols", b, self.obj_hidden).map(|_| ()), + None => { + self.unreadable("symbols", self.obj_hidden); + Ok(()) + } + } + } + /// Drop the object just read when it is an envelope's own: a group on `%_` lines, before the /// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's /// contents are being edited, those contents (the mesh then is in the dictionary); either way the @@ -365,10 +490,15 @@ impl<'a> Reader<'a> { } /// Do the layers being read show? - fn shown(&self) -> bool { + pub(super) fn shown(&self) -> bool { self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) } + /// Is the art being read an opacity mask's? It is on `%_` lines and still shapes what shows. + pub(super) fn in_mask(&self) -> bool { + self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) + } + /// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_` /// lines) and its drawn steps (the group after it) become one live blend, or, where they can't, /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index d2a4b6cc5..1d5806a3c 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1,5 +1,5 @@ use super::art::{four, sane, two}; -use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED}; +use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED}; use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker}; impl<'a> Reader<'a> { @@ -23,7 +23,7 @@ impl<'a> Reader<'a> { // ---- comments -------------------------------------------------------------------------- - pub(super) fn comment(&mut self, c: &[u8]) -> Result<(), String> { + pub(super) fn comment(&mut self, c: &[u8], hidden: bool) -> Result<(), String> { let line = String::from_utf8_lossy(c); let line = line.trim_end(); if let Some((len, hex)) = self.blend_record.as_mut() { @@ -56,6 +56,11 @@ impl<'a> Reader<'a> { self.define_pattern(section); return Ok(()); } + if m == "BeginSymbol" { + let section = self.section("EndSymbol")?; + self.symbol_bounds(section); + return Ok(()); + } if m == "BeginArtStyles" { let section = self.section("EndArtStyles")?; self.styles = crate::import::ai::style::art_styles(section); @@ -107,8 +112,8 @@ impl<'a> Reader<'a> { self.place = Some(Place::default()); } else if line.starts_with("AI5_EndPlace") { // One that `~` didn't end isn't read. - if self.place.take().is_some() { - self.unreadable("placed files"); + if self.place.is_some() { + self.end_place(hidden)?; } } else if self.frames.is_empty() && let Some(v) = line.strip_prefix("%HiResBoundingBox:") @@ -185,13 +190,15 @@ impl<'a> Reader<'a> { /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't - /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art + /// read whole (an operator it doesn't know, type, a pattern it doesn't have, unbalanced groups, + /// no tile) isn't kept: art /// filled with it can't be read. fn define_pattern(&mut self, section: &[u8]) { - if self.pattern_tiles.len() >= MAX_PATTERNS { + if self.pattern_tiles.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS { return; } let mut sub = Reader::new(section); + sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead::default()); // The gradients defined so far, lent (and those it defines kept). sub.gradients = std::mem::take(&mut self.gradients); @@ -200,7 +207,13 @@ impl<'a> Reader<'a> { let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); - let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + // Unread art in the definition, hidden or not, leaves the tile incomplete. + let whole = read.is_ok() + && sub.unsupported.is_empty() + && sub.unknown.is_empty() + && sub.unread.is_empty() + && sub.hidden_unread.is_empty() + && sub.frames.len() == 1; let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { return; @@ -224,6 +237,34 @@ impl<'a> Reader<'a> { } } + /// Read a definition's box for an instance whose art must come from the page. + fn symbol_bounds(&mut self, section: &[u8]) { + if self.symbol_bounds.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS { + return; + } + let mut lex = super::Lexer::new(section); + let name = match lex.next_token().map(|t| t.tok) { + Some(Tok::Str(bytes)) => super::obj::text(&bytes), + _ => return, + }; + let mut sub = Reader::new(section); + sub.subreader_depth = self.subreader_depth + 1; + sub.cropmarks = self.cropmarks; + sub.template = self.template; + sub.nodes = self.nodes; + let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.nodes = sub.nodes; + if read.is_err() || !sub.unsupported.is_empty() || !sub.unknown.is_empty() || sub.frames.len() != 1 { + return; + } + let Some(frame) = sub.frames.pop() else { return }; + let bounds = frame.children.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())); + let Some(bounds) = bounds.map(|b| sub.space().inverse().transform_rect_bbox(b)) else { return }; + if sane(bounds) && bounds.area() > 0.0 { + self.symbol_bounds.insert(name, bounds); + } + } + /// In a pattern's definition, the operands of its first operator start with its head, /// `(name) llx lly urx ury`: taken off them. pub(super) fn take_head(&mut self, mut vals: Vec) -> Vec { @@ -237,3 +278,24 @@ impl<'a> Reader<'a> { vals.split_off(at + 5) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deeply_nested_symbol_definitions_stop_at_subreader_limit() { + let mut definition = String::from("(Outer) 0 Ae\n"); + for i in 0..300 { + definition.push_str(&format!("%AI14_BeginSymbol\n(S{i}) 0 Ae\n")); + } + definition.push_str("0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n"); + for _ in 0..300 { + definition.push_str("%AI10_EndSymbol\n0 0 m\n4 0 L\n4 4 L\n0 4 L\nf\n"); + } + let mut reader = Reader::new(b""); + reader.subreader_depth = MAX_SUBREADERS; + reader.symbol_bounds(definition.as_bytes()); + assert!(reader.symbol_bounds.is_empty()); + } +} diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index fb540f1d1..eeaf10854 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -243,10 +243,20 @@ impl<'a> Reader<'a> { 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"); + self.unreadable("opacity masks without an owning object", false); + } + // What the art doesn't read matters only when the mask shapes what shows. + for what in &mask.unread { + if on("Disabled") { + self.hidden_unread.insert(what.clone()); + } else if let Some(op) = what.strip_prefix('`').and_then(|w| w.strip_suffix('`')) { + self.note_unknown(op, false); + } else { + self.unreadable(what, false); + } } } else if mask.nums("Disabled").first().copied() != Some(1.0) { - self.unreadable("opacity masks without readable art"); + self.unreadable("opacity masks without readable art", false); } } let name = match o.text("AIArtName") { @@ -453,6 +463,8 @@ impl<'a> Reader<'a> { artboard, template: self.template, hidden_unread: self.hidden_unread, + unread: self.unread, + unread_elsewhere: self.unread_elsewhere, slot_names: self.slot_names, restyled: self.restyled, placed_elsewhere: self.placed_elsewhere, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9deba8889..8eff92907 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -80,7 +80,7 @@ impl<'a> Reader<'a> { // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so // the operator is one the reader doesn't know, as before. if w != "Bh" && !self.frames.is_empty() && vals.iter().any(|v| matches!(v, V::Num(n) if n.is_nan())) { - self.note_unknown("--"); + self.note_unknown("--", hidden); return Ok(()); } if let Some(d) = self.gradient_def.as_mut() { @@ -303,7 +303,7 @@ impl<'a> Reader<'a> { _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} - _ => self.note_unknown(w), + _ => self.note_unknown(w, hidden), } Ok(()) } @@ -340,22 +340,38 @@ impl<'a> Reader<'a> { } let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { - self.unreadable("pattern fills"); + self.unread_pattern(w); return; }; let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); let Some(xf) = pattern_xf(self.space(), matrix, tile) else { - self.unreadable("pattern fills"); + self.unread_pattern(w); return; }; let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; if w == "p" { + self.pending_unread_fill = false; self.gs.fill = p; } else { + self.pending_unread_stroke = false; self.gs.stroke = p; } } + fn unread_pattern(&mut self, w: &str) { + if self.shown() { + if w == "p" { + self.pending_unread_fill = true; + } else { + self.pending_unread_stroke = true; + } + } + // In a pattern's definition, only art painted with it makes the tile unreadable (see `paint`). + if self.pattern.is_none() && (!self.shown() || self.gs.hidden) { + self.hidden_unread.insert("pattern fills".into()); + } + } + fn color(&mut self, w: &str, vals: &[V]) { let Some((p, named)) = paint::color_op(w, vals) else { return }; // Registration (`Xs`, `Xz`) says nothing of the document's colour model. @@ -373,9 +389,11 @@ impl<'a> Reader<'a> { if w.ends_with(|c: char| c.is_ascii_lowercase()) { self.gs.fill = p; self.pending_fill = None; + self.pending_unread_fill = false; } else { self.gs.stroke = p; self.pending_stroke = None; + self.pending_unread_stroke = false; } } diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 6d948ae81..a2792e84c 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -3,6 +3,9 @@ use super::{Affine, BlendMode, Dash, FillRule, Knockout, LayerColor, LineCap, Li /// Deepest nesting of layers, groups and dictionaries. pub(super) const MAX_DEPTH: usize = 256; +/// Most nested pattern and symbol readers on one call stack. +pub(super) const MAX_SUBREADERS: usize = 32; + /// Most operands on the stack. pub(super) const MAX_STACK: usize = 1 << 20; @@ -147,12 +150,17 @@ pub(super) struct Frame { /// The state when it opened (a container's lock and visibility; a dictionary's state to go /// back to). pub(super) gs: GState, + /// Unresolved pattern paint saved by `q`, which restores graphics state at `Q`. + pub(super) pending_unread_fill: bool, + pub(super) pending_unread_stroke: bool, /// The operand stack's height when it opened. pub(super) base: usize, /// The clipping paths' indexes among the children. pub(super) clips: Vec, /// A compound member group explicitly declares its subpaths' winding with `D`. pub(super) compound_members: bool, + /// Unread kinds belonging to this top-level object. + pub(super) unread: Vec, /// A dictionary in the art: the transparency (`Xy`) its art set last, which stays after it. pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>, } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 34b35e13e..21227eef6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -33,6 +33,13 @@ fn read_ok(body: &str) -> Structure { } } +fn assert_unread(r: &Structure, kind: &str) { + assert_eq!(r.unread.len(), 1); + let (id, kinds) = &r.unread[0]; + assert_eq!(kinds, &[kind]); + assert!(r.doc.node(*id).is_some(), "the unread object has a placeholder"); +} + #[test] fn ruler_units_from_the_header_set_the_documents_units() { let body = layer(""); @@ -286,18 +293,41 @@ fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() { let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. }))); for name in ["Old", "Odd", "Flat"] { - let e = read( + let r = read( stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))) .as_bytes(), ) - .unwrap_err(); - assert!(e.contains("pattern fills"), "{name}: {e}"); + .unwrap(); + assert_unread(&r, "pattern fills"); } // Type in a pattern isn't read either. let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n"; assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty()); } +/// A pattern whose definition paints with a pattern it doesn't have isn't kept, on `%_` lines or +/// not: a fill with it is something the file has that isn't read. +#[test] +fn a_pattern_painting_with_an_unread_pattern_is_not_kept() { + for prefix in ["%_", ""] { + let tile = rect_path(0.0, 0.0, 8.0, 8.0).replace('\n', &format!("\n{prefix}")); + let def = format!( + "%AI3_BeginPattern: (Nest)\n(Nest) 0 0 8 8\n{prefix}(Missing) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{prefix}{tile}f\nE\n%AI3_EndPattern\n" + ); + let s = read_ok(&format!("{def}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert!(s.doc.patterns.is_empty(), "{prefix:?}: {:?}", s.doc.patterns.iter().map(|p| &p.name).collect::>()); + let r = read( + stream(&format!("{def}{}", layer(&format!("(Nest) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))).as_bytes(), + ) + .unwrap(); + assert_unread(&r, "pattern fills"); + // A color set before the tile is drawn replaces the missing pattern, and the pattern is kept. + let repainted = def.replace(&format!("{prefix}{tile}f"), &format!("{prefix}0 g\n{prefix}{tile}f")); + let s = read_ok(&format!("{repainted}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert!(s.doc.pattern("Nest").is_some(), "{prefix:?}"); + } +} + #[test] fn compound_paths_and_clipping() { let art_ = format!( @@ -982,6 +1012,42 @@ fn a_placed_file_on_commented_lines_is_read() { assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]), "its preview"); } +#[test] +fn hidden_placed_file_ended_by_comment_stays_hidden() { + let mut data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", None, "")); + let end = data.windows(4).position(|w| w == b"%_~\r").unwrap(); + data.drain(end..end + 4); + let r = read(&data).unwrap(); + assert!(art(&r.doc).is_empty()); + assert!(r.unread.is_empty(), "hidden placement has no shown placeholder"); + assert!(r.hidden_unread.contains("placed files")); +} + +#[test] +fn previewless_placement_records_box_corner_as_alternative() { + let mut data = placed(true, "[1 0 0 -1 -7970 8070]", "missing.png", None, ""); + let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap(); + data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n"); + let r = read(&data).unwrap(); + assert_unread(&r, "placed files"); + assert_eq!(r.unread_elsewhere.len(), 1); + assert_eq!(r.unread_elsewhere[0].0, r.unread[0].0); + assert_ne!(r.unread_elsewhere[0].1, r.doc.node(r.unread[0].0).unwrap().visual_bounds().unwrap()); +} + +/// A previewless locked placement keeps its edit lock on the page-art placeholder. +#[test] +fn previewless_placement_placeholder_inherits_lock() { + let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, ""); + let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap(); + data.splice(at..at, *b"1 A\n"); + let result = read(&data).unwrap(); + assert_unread(&result, "placed files"); + let node = result.doc.node(result.unread[0].0).unwrap(); + assert!(node.locked); + assert!(node.visible); +} + #[test] fn placed_files_are_linked_images_showing_their_preview() { // A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels. @@ -1011,8 +1077,7 @@ fn placed_files_are_linked_images_showing_their_preview() { let xf = pixels(&r.doc).1; assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(100.0, 50.0), Point::new(110.0, 30.0))); - // Without a preview it can read (none, cut short, not 32 bits, absurd sizes), without a path or - // a matrix, the file isn't read from its editing data. + // A placed file with a box but no usable preview is marked for page-art replacement. let cut = &preview[..preview.len() - 6]; let huge = b"70000 70000 280000 32 17 \0\0\0\0"; let overflow = b"1 2 18446744073709551615 32 17 \0\0\0\0\0\0\0\0"; @@ -1027,8 +1092,13 @@ fn placed_files_are_linked_images_showing_their_preview() { ("[0 0 0 0 0 0]", "a.png", Some(&preview[..])), ("[1 2 3]", "a.png", Some(&preview[..])), ] { - let e = read(&placed(true, matrix, path, preview, "")).unwrap_err(); - assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}"); + let result = read(&placed(true, matrix, path, preview, "")); + if matrix == "[0 0 0 0 0 0]" || matrix == "[1 2 3]" { + let e = result.err().unwrap_or_else(|| panic!("expected missing bounds for {matrix} {path}")); + assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}"); + } else { + assert_unread(&result.unwrap(), "placed files"); + } } // On a layer that doesn't show, such a file is left out of it. let r = read(&placed(false, "[5 0 0 -5 0 200]", "a.png", None, "")).unwrap(); @@ -1070,8 +1140,8 @@ fn text_objects_are_slots_named_after_their_story() { fn what_it_doesnt_read_is_an_error_where_it_shows() { let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err(); assert!(e.contains("`frobnicate`"), "{e}"); - let e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err(); - assert!(e.contains("pattern fills"), "{e}"); + let r = read(stream(&layer(&format!("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))).as_bytes()).unwrap(); + assert_unread(&r, "pattern fills"); let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err(); assert!(e.contains("symbols"), "{e}"); // A mask with no owner must still be reported (#2004). @@ -1093,6 +1163,206 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() { read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer(""))); } +#[test] +fn unresolved_pattern_reset_before_group_path_keeps_live_art() { + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!("u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{reset}\n{}{draw}\nU\n", rect_path(0.0, 0.0, 4.0, 4.0),); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread); + let children = art(&result.doc); + assert_eq!(children.len(), 1, "{operator}: group count"); + let group_art = children[0].children().expect("group children"); + assert_eq!(group_art.len(), 1, "{operator}: live path count"); + assert!(matches!(group_art[0].kind, NodeKind::Path { .. })); + } +} + +#[test] +fn unresolved_pattern_in_dictionary_does_not_mark_following_path_unread() { + for (operator, draw) in [("p", "f"), ("P", "S")] { + let body = format!("/Unused :\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread); + let children = art(&result.doc); + assert_eq!(children.len(), 1, "{operator}: live path count"); + assert!(matches!(children[0].kind, NodeKind::Path { .. })); + } +} + +#[test] +fn color_in_dictionary_does_not_clear_outer_unresolved_pattern() { + for (operator, draw, color) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n/Unused :\n{color}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),); + let result = read_ok(&layer(&body)); + assert_unread(&result, "pattern fills"); + assert_eq!(art(&result.doc).len(), 1, "{operator}: unread path placeholder"); + } +} + +#[test] +fn gradient_instance_after_unresolved_pattern_is_read() { + let defs = + "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + for (operator, flag, draw) in [("p", "", "f"), ("P", "1 ", "S")] { + let body = format!( + "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\n{}{flag}Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\n{draw}\n0 BB\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read(stream(&format!("{defs}{}", layer(&body))).as_bytes()).unwrap(); + assert_unread(&result, "pattern fills"); + let a = art(&result.doc); + assert_eq!(a.len(), 2, "{operator}"); + assert_eq!(result.unread[0].0, a[0].id, "{operator}: only the pattern's path is unread"); + } +} + +#[test] +fn unresolved_pattern_in_mask_art_stays_unread() { + let mask_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::(); + let patterned = format!("%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{mask_path}%_f\n"); + // A placed file with no preview, and a symbol whose definition the file doesn't have. + let placed = "%_%AI5_BeginPlace\n%_%%FileType: 1\n%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\n%_(missing.png)`\n%_%%IncludeFile:missing.png\n%_~\n%_%AI5_EndPlace\n"; + let symbol = "%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n"; + for (art_, kind) in [ + (patterned.as_str(), "pattern fills"), + ("%_/Mesh X!\n%_/End X!\n", "gradient meshes it can't read"), + (placed, "placed files"), + (symbol, "symbols"), + ("%_1 2 frobnicate\n", "`frobnicate`"), + ] { + let mask = format!( + "%_/ArtDictionary :\n%_/Mask :\n%_X=\n{art_}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n" + ); + let masked = format!("{RED}{}f\n{mask}", rect_path(0.0, 0.0, 4.0, 4.0)); + let e = read(stream(&layer(&masked)).as_bytes()).unwrap_err(); + assert!(e.contains(kind), "{e}"); + // In a group, the group is the unread object. + let result = read_ok(&layer(&format!("u\n{masked}U\n"))); + assert_unread(&result, kind); + assert!(result.hidden_unread.is_empty(), "{kind}: {:?}", result.hidden_unread); + // A disabled mask doesn't change how its object looks: what it holds is left out. + let disabled = masked.replace("%_1 /Bool (Linked) ,\n", "%_1 /Bool (Linked) ,\n%_1 /Bool (Disabled) ,\n"); + for body in [disabled.clone(), format!("u\n{disabled}U\n")] { + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{kind}: {:?}", result.unread); + assert!(result.hidden_unread.contains(kind), "{kind}: {:?}", result.hidden_unread); + } + } + // Operators a mask's art doesn't know are kept up to 16. + let ops = (0..40).map(|i| format!("%_op{i}\n")).collect::(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{ops}%_X+\n%_(Art) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let result = read_ok(&layer(&format!("u\n{RED}{}f\n{mask}U\n", rect_path(0.0, 0.0, 4.0, 4.0)))); + assert_eq!(result.unread.len(), 1); + assert_eq!(result.unread[0].1.len(), 16); +} + +#[test] +fn hidden_unread_art_beside_drawn_look_needs_no_page_replacement() { + let hidden_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::(); + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!( + "u\n%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{hidden_path}%_{draw}\n{reset}\n{}{draw}\nU\n", + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread); + assert!(result.hidden_unread.contains("pattern fills")); + let children = art(&result.doc); + let group_art = children[0].children().expect("group children"); + assert_eq!(group_art.len(), 2); + assert!(matches!(group_art[1].kind, NodeKind::Path { .. })); + } +} + +#[test] +fn unknown_operator_on_hidden_line_does_not_mark_drawn_group_unread() { + let body = format!("u\n%_1 2 frobnicate\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{:?}", result.unread); + assert!(result.hidden_unread.contains("`frobnicate`")); + assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1); +} + +/// A commented closing delimiter does not hide a visible group's unread content. +#[test] +fn visible_group_closed_on_hidden_line_stays_unread() { + let body = format!("u\n1 2 frobnicate\n{RED}{}f\n%_U\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 1); + assert!(result.unread[0].1.contains(&"`frobnicate`".to_string())); + assert!(!result.hidden_unread.contains("`frobnicate`")); +} + +/// `Xw` hides unread objects even when their operators are on ordinary lines. +#[test] +fn xw_hidden_unread_keeps_visible_group_sibling() { + for unread in + ["1 2 frobnicate\n", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n", "/SymbolInstance :\n(Absent) /SymbolRef ,\n;\n"] + { + let body = format!("u\n1 Xw\n{unread}0 Xw\n0 0 0 1 k\n{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{unread}: {:?}", result.unread); + assert!(!result.hidden_unread.is_empty(), "{unread}"); + let children = art(&result.doc); + let group_art = children[0].children().expect("group children"); + assert_eq!(group_art.iter().filter(|node| node.visible).count(), 1, "{unread}"); + } + let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "0 Xw\n"); + let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap(); + data.splice(at..at, *b"1 Xw\n"); + let result = read(&data).unwrap(); + assert!(result.unread.is_empty()); + assert!(result.hidden_unread.contains("placed files")); +} + +#[test] +fn hidden_unread_symbol_does_not_mark_drawn_group_unread() { + let body = format!("u\n%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let result = read_ok(&layer(&body)); + assert!(result.unread.is_empty(), "{:?}", result.unread); + assert!(result.hidden_unread.contains("symbols")); + assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1); +} + +#[test] +fn unresolved_pattern_paint_persists_past_plain_group_until_replaced() { + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!( + "u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nU\n{}{draw}\n{reset}\n{}{draw}\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + rect_path(20.0, 0.0, 24.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 2, "{operator}: {:?}", result.unread); + assert_eq!(art(&result.doc).len(), 3); + } +} + +#[test] +fn unresolved_pattern_paint_inside_clip_restores_at_q() { + for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] { + let body = format!( + "q\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nQ\n{}{draw}\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread); + assert_eq!(art(&result.doc).len(), 2); + + let body = format!( + "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\nq\n{reset}\n{}{draw}\nQ\n{}{draw}\n", + rect_path(0.0, 0.0, 4.0, 4.0), + rect_path(10.0, 0.0, 14.0, 4.0), + ); + let result = read_ok(&layer(&body)); + assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread); + assert_eq!(result.unread[0].0, art(&result.doc)[1].id); + } +} + #[test] fn sections_it_skips() { let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\ diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index efbb8a3a1..8744db0ea 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -90,7 +90,7 @@ mod tests { ring(&mut layers, Some(MADE), red, 6.0, square); ring(&mut layers, Some(MADE), red, 2.0, Rect::new(40.0, 40.0, 60.0, 60.0)); let rect = page.artboards[0].rect; - let frame = Frame { page: &page, page_rect: rect, rect, whole: false }; + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; let mut view = PageView::new(&frame); assert_eq!(prune_unseen(&frame, &mut view, &mut layers, false), 1, "only the ring the page doesn't draw"); let widths: Vec = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect(); @@ -192,7 +192,7 @@ mod tests { 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 frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; let mut view = PageView::new(&frame); assert!(compare(&frame, &mut view, &vector, false).is_ok()); assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0), false).is_err(), "missing artwork remains an error"); diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 1dd8b7d2b..0ed2f354f 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -26,6 +26,8 @@ pub(super) struct Frame<'a> { /// The page holds all the art that prints (an EPS's page is its art's box, unless it was /// saved as its artboard): the layers print nothing outside it. pub(super) whole: bool, + /// The PDF page areas and their moves into the editing document. + pub(super) pages: Vec<(Rect, Affine)>, } /// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the @@ -57,7 +59,20 @@ pub(super) fn build_from( trust: bool, unit: f64, ) -> Result<(Document, Vec), String> { - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, placed_elsewhere, warnings } = structure; + let Structure { + mut doc, + to_doc, + bbox, + artboard, + template, + hidden_unread, + unread, + unread_elsewhere, + slot_names, + restyled, + placed_elsewhere, + warnings, + } = structure; // Editing data in the units of the page's user space ([`in_units`]) is compared with the page // scaled down to those units, and the document read from it is scaled up by `unit`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); @@ -132,6 +147,15 @@ pub(super) fn build_from( // goes where the layers have that artboard. let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); let boards = page_boards(&doc.artboards, first, &pages); + let page_moves: Vec<(Rect, Affine)> = pages + .iter() + .enumerate() + .map(|(i, page)| { + let move_to_doc = + boards.and_then(|boards| boards.get(i)).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift); + (*page, move_to_doc) + }) + .collect(); // The page with most of an object, the first for what is on none. let page_of = |object: &[Arc]| { extent(object).and_then(|r| { @@ -167,7 +191,7 @@ pub(super) fn build_from( a.rect = rect; } } - frame = Some(Frame { page: visible, page_rect, rect, whole }); + frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves }); } let mut infos = vec![]; collect_slots(&doc.layers, true, &mut infos); @@ -180,6 +204,9 @@ pub(super) fn build_from( if outlined && infos.iter().any(|i| i.shown) { return Err("its type is to come in as outlines, which only its page has".into()); } + // The page's type inside an unread object's box comes in with the page's art that replaces the + // object (see [`replace_unread`]), not in a text object. + let unread_type = take_unread_type(&doc, frame.as_ref(), &unread, &unread_elsewhere, &mut shown); // The page's type that needs no text object of the layers: the layers' own legacy text, or // placed files that show it. let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; @@ -214,8 +241,22 @@ pub(super) fn build_from( let mut made = vec![]; fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made, &mut vec![]); doc.layers = layers; - // A story in several frames is threaded type: its frames in order (the first holds the story). made.sort(); + let threaded_frames: BTreeSet = + made.chunk_by(|a, b| a.0 == b.0).filter(|thread| thread.len() > 1).flatten().map(|(_, _, id)| *id).collect(); + if !unread.is_empty() { + let Some(frame) = frame.as_ref() else { return Err(unread_error(&unread)) }; + let mut copied = BTreeSet::new(); + let kinds = replace_unread(&mut doc, frame, &unread, &unread_elsewhere, &threaded_frames, &mut copied)?; + // With the text objects filled, the page art chosen can differ from the first try's: the + // page's type set aside for it, and no other, must be in it. + let all_copied = |o: &Vec>| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id)); + if !unread_type.iter().all(all_copied) || shown.iter().any(all_copied) { + return Err("the page's type in an unread object's box isn't in its page art".into()); + } + notes.push(format!("{}: they came in as the page's art", kinds.join(", "))); + } + // A story in several frames is threaded type: its frames in order (the first holds the story). for thread in made.chunk_by(|a, b| a.0 == b.0) { if thread.len() > 1 { doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect()); @@ -285,6 +326,406 @@ pub(super) fn build_from( Ok((doc, notes)) } +/// A visible page node, with its source order and subtree range. +struct PageArt { + node: Arc, + bounds: Rect, + page: usize, + end: usize, + stateful_ancestor: bool, +} + +const ART_MARGIN: f64 = 1.0; + +fn inside(art: Rect, bounds: Rect) -> bool { + art.x0 >= bounds.x0 - ART_MARGIN && art.y0 >= bounds.y0 - ART_MARGIN && art.x1 <= bounds.x1 + ART_MARGIN && art.y1 <= bounds.y1 + ART_MARGIN +} + +/// Pick the page holding the most of a box. The first page wins a tie. +fn page_for(bounds: Rect, pages: &[Rect]) -> usize { + let mut best = (0, 0.0); + for (i, page) in pages.iter().enumerate() { + let area = page.intersect(bounds).area().max(0.0); + if area > best.1 { + best = (i, area); + } + } + best.0 +} + +fn collect_live(node: &Node, unread: &BTreeSet, live: &mut Vec) { + if !node.visible || unread.contains(&node.id) { + return; + } + if let Some(children) = node.children() { + for child in children { + collect_live(child, unread, live); + } + } else if let Some(bounds) = node.visual_bounds() { + live.push(bounds); + } +} + +fn page_node_has_drawing_state(node: &Node) -> bool { + node.clips() + || matches!(node.kind, NodeKind::Path { clipping: true, .. }) + || node.opacity < 1.0 + || node.blend != super::BlendMode::Normal + || node.isolate + || node.knockout != vectorcraft_doc::Knockout::Neutral + || node.extras().mask.is_some() + || !node.appearance.items.is_empty() + || !node.appearance.effects.is_empty() +} + +fn index_page(node: &Arc, pages: &[Rect], moves: &[Affine], art: &mut Vec, stateful_ancestor: bool) { + if !node.visible { + return; + } + let start = art.len(); + let mut indexed = false; + if let Some(bounds) = node.visual_bounds() { + let page = page_for(bounds, pages); + if let Some(shift) = moves.get(page) { + art.push(PageArt { node: Arc::clone(node), bounds: shift.transform_rect_bbox(bounds), page, end: start + 1, stateful_ancestor }); + indexed = true; + } + } + if let Some(children) = node.children() { + for child in children { + index_page(child, pages, moves, art, stateful_ancestor || page_node_has_drawing_state(node)); + } + } + let end = art.len(); + if indexed && let Some(entry) = art.get_mut(start) { + entry.end = end; + } +} + +/// Node IDs belong to the importing document, not to the pattern's drawing. +fn same_pattern_art(existing: &vectorcraft_doc::PatternDef, source: &vectorcraft_doc::PatternDef) -> bool { + fn clear_ids(node: &mut Node) { + node.id = NodeId(0); + if let Some(children) = node.children_mut() { + for child in children { + clear_ids(Arc::make_mut(child)); + } + } + if let Some(mask) = &mut node.extras_mut().mask { + clear_ids(Arc::make_mut(&mut mask.art)); + } + } + + let mut a = existing.clone(); + let mut b = source.clone(); + for pattern in [&mut a, &mut b] { + for node in &mut pattern.art { + clear_ids(Arc::make_mut(node)); + } + } + a == b +} + +/// Bring document resources used by page art across before reassigning its node IDs. +fn copy_page_resources(doc: &mut Document, page: &Document, nodes: &[&Node]) -> Result<(), String> { + fn collect(node: &Node, images: &mut BTreeSet, patterns: &mut BTreeSet, swatches: &mut BTreeSet) { + if let NodeKind::Image(im) = &node.kind { + images.insert(im.key.clone()); + } + for item in &node.appearance.items { + match item.paint() { + vectorcraft_color::Paint::Solid { swatch: Some(name), .. } => { + swatches.insert(name.clone()); + } + vectorcraft_color::Paint::Gradient(gradient) => { + if let Some(name) = &gradient.swatch { + swatches.insert(name.clone()); + } + for stop in &gradient.gradient.stops { + if let Some(name) = &stop.swatch { + swatches.insert(name.clone()); + } + } + } + vectorcraft_color::Paint::Pattern { pattern, .. } => { + patterns.insert(pattern.clone()); + } + _ => {} + } + } + if let Some(mask) = &node.extras().mask { + collect(&mask.art, images, patterns, swatches); + } + for child in node.children().into_iter().flatten() { + collect(child, images, patterns, swatches); + } + } + + let (mut images, mut patterns, mut swatches) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new()); + for node in nodes { + collect(node, &mut images, &mut patterns, &mut swatches); + } + let mut copied = BTreeSet::new(); + while let Some(name) = patterns.pop_first() { + if !copied.insert(name.clone()) { + continue; + } + let Some(source) = page.pattern(&name) else { return Err(format!("page art is missing pattern {name}")) }; + if let Some(existing) = doc.pattern(&name) { + if !same_pattern_art(existing, source) { + return Err(format!("page art conflicts with pattern {name}")); + } + } else { + for node in &source.art { + collect(node, &mut images, &mut patterns, &mut swatches); + } + let mut pattern = source.clone(); + pattern.art = pattern.art.iter().map(|node| Arc::new(reid_page_art(doc, node))).collect(); + doc.patterns.push(pattern); + } + } + for key in images { + let Some(blob) = page.images.get(&key) else { return Err(format!("page art is missing image data for {key}")) }; + if doc.images.get(&key).is_some_and(|existing| existing.bytes != blob.bytes) { + return Err(format!("page art conflicts with image data for {key}")); + } + doc.images.entry(key).or_insert_with(|| blob.clone()); + } + for name in swatches { + let Some(source) = page.swatch(&name) else { return Err(format!("page art is missing swatch {name}")) }; + if let Some(existing) = doc.swatch(&name) { + if existing != source { + return Err(format!("page art conflicts with swatch {name}")); + } + } else { + doc.swatches.push(source.clone()); + } + } + Ok(()) +} + +/// The PDF importer keeps group transparency on a group, or folds it into its only child. +/// If the editing object's state cannot be located in that art, use the PDF page as a whole. +/// Every selected page object must carry the state, on itself or down a chain of only children. +fn page_carries_state(editing: &Node, page: &[&Node]) -> bool { + fn carried(node: &Node, matches: &dyn Fn(&Node) -> bool) -> bool { + matches(node) + || match node.children().map(Vec::as_slice) { + Some([only]) => carried(only, matches), + _ => false, + } + } + let has = |matches: &dyn Fn(&Node) -> bool| !page.is_empty() && page.iter().all(|node| carried(node, matches)); + let opacity = editing.opacity >= 0.999 || has(&|node: &Node| (node.opacity - editing.opacity).abs() < 0.001); + let blend = editing.blend == super::BlendMode::Normal || has(&|node: &Node| node.blend == editing.blend); + let isolate = !editing.isolate || has(&|node: &Node| node.isolate); + let knockout = matches!(editing.knockout, vectorcraft_doc::Knockout::Neutral | vectorcraft_doc::Knockout::Off) + || has(&|node: &Node| node.knockout == editing.knockout); + let appearance = !matches!(editing.kind, NodeKind::Group { .. }) || *editing.appearance == vectorcraft_doc::Appearance::default(); + opacity && blend && isolate && knockout && appearance +} + +/// The error for unread objects with no page art to use: what isn't read, as the reader names it. +fn unread_error(unread: &[(NodeId, Vec)]) -> String { + let kinds: BTreeSet<&str> = unread.iter().flat_map(|(_, kinds)| kinds.iter().map(String::as_str)).collect(); + let (ops, other): (Vec<&str>, Vec<&str>) = kinds.into_iter().partition(|k| k.starts_with('`')); + let mut list: BTreeSet = other.into_iter().map(String::from).collect(); + if !ops.is_empty() { + list.insert(format!("operators VectorCraft doesn't know ({})", ops.join(", "))); + } + let list: Vec<&str> = list.iter().map(String::as_str).collect(); + format!("it has {}, which VectorCraft doesn't read from it yet", ai::join_and(&list)) +} + +/// The page's text objects in `shown` that the page objects replacing `doc`'s unread objects hold +/// (see [`replace_unread`], tried on a copy of `doc` before its text objects are filled), taken out +/// of `shown`. +fn take_unread_type( + doc: &Document, + frame: Option<&Frame<'_>>, + unread: &[(NodeId, Vec)], + elsewhere: &[(NodeId, Rect)], + shown: &mut Vec>>, +) -> Vec>> { + let Some(frame) = frame.filter(|_| !unread.is_empty() && !shown.is_empty()) else { return vec![] }; + let mut copied = BTreeSet::new(); + if replace_unread(&mut doc.clone(), frame, unread, elsewhere, &BTreeSet::new(), &mut copied).is_err() { + return vec![]; + } + let (taken, kept) = std::mem::take(shown).into_iter().partition(|o| !o.is_empty() && o.iter().all(|n| copied.contains(&n.id))); + *shown = kept; + taken +} + +/// Replace each unread top-level object with painted page objects inside its readable bounds. +/// `copied` gets the ids of the page's nodes copied. +fn replace_unread( + doc: &mut Document, + frame: &Frame<'_>, + unread: &[(NodeId, Vec)], + elsewhere: &[(NodeId, Rect)], + threaded_frames: &BTreeSet, + copied: &mut BTreeSet, +) -> Result, String> { + fn holds_threaded_frame(node: &Node, frames: &BTreeSet) -> bool { + frames.contains(&node.id) || node.children().is_some_and(|children| children.iter().any(|child| holds_threaded_frame(child, frames))) + } + fn subtree(node: &Node, ids: &mut BTreeSet) { + ids.insert(node.id); + for child in node.children().into_iter().flatten() { + subtree(child, ids); + } + } + + let unread_ids: BTreeSet = unread.iter().map(|(id, _)| *id).collect(); + let mut live = Vec::new(); + for layer in &doc.layers { + collect_live(layer, &unread_ids, &mut live); + } + live.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + + let source_pages: Vec = frame.pages.iter().map(|(page, _)| *page).collect(); + let moves: Vec = frame.pages.iter().map(|(_, shift)| *shift).collect(); + let destination_pages: Vec = frame.pages.iter().map(|(page, shift)| shift.transform_rect_bbox(*page)).collect(); + let mut art = Vec::new(); + for layer in &frame.page.layers { + if layer.visible + && let Some(children) = layer.children() + { + for child in children { + index_page(child, &source_pages, &moves, &mut art, false); + } + } + } + let mut by_x: Vec = (0..art.len()).collect(); + by_x.sort_by(|a, b| art[*a].bounds.x0.total_cmp(&art[*b].bounds.x0)); + let mut claimed = std::collections::BTreeMap::::new(); + let mut counts = std::collections::BTreeMap::::new(); + for (id, kinds) in unread { + let Some((bounds, name, visible, locked, editing)) = + doc.node(*id).and_then(|n| n.visual_bounds().map(|b| (b, n.name.clone(), n.visible, n.locked, n.clone()))) + else { + return Err("an unread object has no readable bounds".into()); + }; + if !bounds.area().is_finite() || bounds.area() <= 0.0 { + return Err("an unread object has no readable bounds".into()); + } + let mut selected = Vec::new(); + let mut page = 0; + let mut first_rejection = None; + 'candidate: for candidate in std::iter::once(bounds).chain(elsewhere.iter().filter(|(other, _)| *other == *id).map(|(_, b)| *b)) { + let live_start = live.partition_point(|b| b.x0 < candidate.x0 - ART_MARGIN); + let live_end = live.partition_point(|b| b.x0 <= candidate.x1 + ART_MARGIN); + if live[live_start..live_end].iter().any(|art| inside(*art, candidate)) { + first_rejection.get_or_insert("an unread object's bounds also contain live art"); + continue; + } + // Each page has its own copy of art across artboards: one page's art can't stand in for it. + if destination_pages.iter().filter(|page| page.intersect(candidate).area() > 0.0).count() > 1 { + first_rejection.get_or_insert("an unread object lies on more than one artboard"); + continue; + } + let candidate_page = page_for(candidate, &destination_pages); + // An artboard that has no page (a PDF part saved with some of the artboards) has no art + // on the page for the object's part on it. + if let Some(page) = destination_pages.get(candidate_page) + && doc.artboards.iter().map(|a| a.rect.intersect(candidate)).any(|part| part.area() > page.intersect(part).area().max(0.0)) + { + first_rejection.get_or_insert("an unread object lies on an artboard that has no page"); + continue; + } + let start = by_x.partition_point(|i| art[*i].bounds.x0 < candidate.x0 - ART_MARGIN); + let end = by_x.partition_point(|i| art[*i].bounds.x0 <= candidate.x1 + ART_MARGIN); + let mut candidates: Vec = + by_x[start..end].iter().copied().filter(|i| art[*i].page == candidate_page && inside(art[*i].bounds, candidate)).collect(); + candidates.sort_unstable(); + let mut covered_until = 0; + let mut candidate_selected = Vec::new(); + for i in candidates { + if i < covered_until || matches!(art[i].node.kind, NodeKind::Path { clipping: true, .. }) { + continue; + } + if art[i].stateful_ancestor { + first_rejection.get_or_insert("an unread object's page art depends on a clipping or appearance ancestor"); + continue 'candidate; + } + covered_until = art[i].end; + candidate_selected.push(i); + } + if !candidate_selected.is_empty() + && !page_carries_state(&editing, &candidate_selected.iter().map(|&i| art[i].node.as_ref()).collect::>()) + { + first_rejection.get_or_insert("an unread object's appearance is not identifiable in the page art"); + continue; + } + if candidate_selected.iter().any(|&i| claimed.range(..art[i].end).next_back().is_some_and(|(_, end)| *end > i)) { + first_rejection.get_or_insert("one page object matches more than one unread object"); + continue; + } + if !candidate_selected.is_empty() { + selected = candidate_selected; + page = candidate_page; + break; + } + } + if selected.is_empty() { + return Err(first_rejection.unwrap_or("an unread object's page art wasn't found in its bounds").into()); + } + if holds_threaded_frame(&editing, threaded_frames) { + return Err("an unread object holds a frame of threaded type".into()); + } + copy_page_resources(doc, frame.page, &selected.iter().map(|&i| art[i].node.as_ref()).collect::>())?; + let mut found = Vec::new(); + for &i in &selected { + let mut copy = Node::clone(&art[i].node); + if let Some(shift) = moves.get(page) { + copy.transform(*shift, false); + } + found.push(Arc::new(reid_page_art(doc, ©))); + claimed.insert(i, art[i].end); + subtree(&art[i].node, copied); + } + let mut replacement = Node::group(*id, found); + replacement.name = name; + replacement.visible = visible; + replacement.locked = locked; + let Some(target) = doc.node_mut(*id) else { return Err("an unread object is missing from its layer".into()) }; + *target = replacement; + for kind in kinds { + *counts.entry(kind.clone()).or_default() += 1; + } + } + Ok(counts.into_iter().map(|(kind, count)| format!("{kind} ({count})")).collect()) +} + +/// Copy page drawing IDs, including mask art outside the ordinary child tree. +fn reid_page_art(doc: &mut Document, source: &Node) -> Node { + let mut copy = doc.reid(source); + if let Some(mask) = &mut copy.extras_mut().mask { + mask.art = Arc::new(reid_page_art(doc, &mask.art)); + } + if let Some(children) = copy.children_mut() { + for child in children { + let node = reid_page_art_masks(doc, child); + *child = Arc::new(node); + } + } + copy +} + +fn reid_page_art_masks(doc: &mut Document, source: &Node) -> Node { + let mut copy = source.clone(); + if let Some(mask) = &mut copy.extras_mut().mask { + mask.art = Arc::new(reid_page_art(doc, &mask.art)); + } + if let Some(children) = copy.children_mut() { + for child in children { + *child = Arc::new(reid_page_art_masks(doc, child)); + } + } + copy +} + /// What goes into each of `infos`' slots, and whether the type is all made from the file's text /// document. When the text document makes the type of every text object that shows (and the type /// isn't asked for as outlines: `own` has the page's text objects that show outside placed files), @@ -315,18 +756,20 @@ fn slot_content( /// `doc` with the placed files `placed` lists at their other placement (see /// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when -/// none is listed. +/// no listed image survives replacement. fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option { if placed.is_empty() { return None; } let mut doc = doc.clone(); + let mut moved = false; for (id, shift) in placed { if let Some(NodeKind::Image(im)) = doc.node_mut(*id).map(|n| &mut n.kind) { im.xf *= *shift; + moved = true; } } - Some(doc) + moved.then_some(doc) } /// The page's type that needs no text object of the layers, taken out of `shown` and `hidden` (the @@ -599,3 +1042,304 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc } removed } + +#[cfg(test)] +mod candidate_tests { + use super::*; + + use vectorcraft_doc::{ImageBlob, ImageObject}; + + /// A page image with a different proxy keeps its editing layer and original blob. + #[test] + fn page_image_with_different_proxy_keeps_editing_layer() { + let mut doc = Document::new(100.0, 100.0); + let placeholder_id = doc.alloc_id(); + let placeholder = Node::path(placeholder_id, vectorcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 54.0, 54.0)), Default::default()); + doc.insert(Some(doc.layers[0].id), 0, placeholder).unwrap(); + + let mut page = Document::new(100.0, 100.0); + let mut editing_blob = ImageBlob::new("image/png", vec![1, 2, 3]); + editing_blob.proxy = Some(Arc::new(vec![4, 5, 6])); + let page_blob = ImageBlob::new("image/png", vec![1, 2, 3]); + let key = editing_blob.content_key(); + assert_eq!(key, page_blob.content_key()); + doc.images.insert(key.clone(), editing_blob.clone()); + page.images.insert(key.clone(), page_blob); + let image = Node::new( + page.alloc_id(), + NodeKind::Image(Box::new(ImageObject { + key: key.clone(), + width: 4, + height: 4, + xf: Affine::translate(Vec2::new(50.0, 50.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + page.insert(Some(page.layers[0].id), 0, image).unwrap(); + + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let kinds = replace_unread( + &mut doc, + &frame, + &[(placeholder_id, vec!["placed files".into()])], + &[(placeholder_id, Rect::new(50.0, 50.0, 54.0, 54.0))], + &BTreeSet::new(), + &mut BTreeSet::new(), + ) + .unwrap(); + assert_eq!(kinds, ["placed files (1)"]); + let replacement = doc.node(placeholder_id).unwrap(); + let [image] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") }; + assert!(matches!(image.kind, NodeKind::Image(_))); + assert_eq!(doc.images.get(&key), Some(&editing_blob)); + } + + /// An unread object across two artboards isn't replaced by the art of one page only. + #[test] + fn unread_object_across_two_pages_is_not_replaced() { + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + doc.insert( + Some(doc.layers[0].id), + 0, + Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()), + ) + .unwrap(); + let mut page = Document::new(200.0, 100.0); + for bounds in [Rect::new(91.0, 11.0, 99.0, 19.0), Rect::new(101.0, 11.0, 109.0, 19.0)] { + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let (first, second) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0)); + let frame = + Frame { page: &page, page_rect: first, rect: first, whole: false, pages: vec![(first, Affine::IDENTITY), (second, Affine::IDENTITY)] }; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object lies on more than one artboard"); + } + + /// An unread object reaching an artboard that has no page isn't replaced by the page's part of it. + #[test] + fn unread_object_on_an_artboard_without_a_page_is_not_replaced() { + let mut doc = Document::new(100.0, 100.0); + doc.artboards.push(vectorcraft_doc::Artboard { id: 2, rect: Rect::new(100.0, 0.0, 200.0, 100.0), ..doc.artboards[0].clone() }); + let id = doc.alloc_id(); + doc.insert( + Some(doc.layers[0].id), + 0, + Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 110.0, 20.0)), Default::default()), + ) + .unwrap(); + let mut page = Document::new(100.0, 100.0); + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(91.0, 11.0, 99.0, 19.0)), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + page.insert(Some(page.layers[0].id), 0, art).unwrap(); + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page"); + + // Half a point onto it is still on it. + let narrow = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 10.0, 100.5, 20.0)), Default::default()); + *doc.node_mut(id).unwrap() = narrow; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object lies on an artboard that has no page"); + } + + /// A nested placeholder's corner cannot replace its readable sibling. + #[test] + fn nested_placeholder_corner_cannot_replace_readable_sibling() { + let mut doc = Document::new(100.0, 100.0); + let group_id = doc.alloc_id(); + let placeholder = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()); + let placeholder_id = placeholder.id; + let sibling = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(70.0, 70.0, 74.0, 74.0)), Default::default()); + let sibling_id = sibling.id; + let group = Node::group(group_id, vec![Arc::new(placeholder), Arc::new(sibling)]); + doc.insert(Some(doc.layers[0].id), 0, group).unwrap(); + + let mut page = Document::new(100.0, 100.0); + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(81.0, 81.0, 89.0, 89.0)), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + page.insert(Some(page.layers[0].id), 0, art).unwrap(); + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let result = replace_unread( + &mut doc, + &frame, + &[(group_id, vec!["placed files".into()])], + &[(placeholder_id, Rect::new(80.0, 80.0, 90.0, 90.0))], + &BTreeSet::new(), + &mut BTreeSet::new(), + ); + assert_eq!(result.unwrap_err(), "an unread object's page art wasn't found in its bounds"); + let group = doc.node(group_id).unwrap(); + assert!(group.children().unwrap().iter().any(|child| child.id == sibling_id)); + } + + /// An alternative box may carry opacity missing from the first box. + #[test] + fn alternative_box_can_carry_opacity_when_first_box_cannot() { + let mut doc = Document::new(100.0, 100.0); + let id = doc.alloc_id(); + let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()); + editing.opacity = 0.5; + doc.insert(Some(doc.layers[0].id), 0, editing).unwrap(); + + let mut page = Document::new(100.0, 100.0); + for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 1.0), (Rect::new(51.0, 51.0, 59.0, 59.0), 0.5)] { + let mut art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + art.opacity = opacity; + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let kinds = replace_unread( + &mut doc, + &frame, + &[(id, vec!["placed files".into()])], + &[(id, Rect::new(50.0, 50.0, 60.0, 60.0))], + &BTreeSet::new(), + &mut BTreeSet::new(), + ) + .unwrap(); + assert_eq!(kinds, ["placed files (1)"]); + let replacement = doc.node(id).unwrap(); + let [art] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") }; + assert_eq!(art.opacity, 0.5); + assert_eq!(art.geometric_bounds(), Some(Rect::new(51.0, 51.0, 59.0, 59.0))); + } + + /// Opacity on one of two page objects in the box is not the unread object's opacity. + #[test] + fn opacity_on_one_of_two_page_objects_is_not_identifiable() { + let mut doc = Document::new(100.0, 100.0); + let id = doc.alloc_id(); + let mut editing = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Default::default()); + editing.opacity = 0.5; + doc.insert(Some(doc.layers[0].id), 0, editing).unwrap(); + + let mut page = Document::new(100.0, 100.0); + for (bounds, opacity) in [(Rect::new(11.0, 11.0, 19.0, 19.0), 0.5), (Rect::new(21.0, 21.0, 39.0, 39.0), 1.0)] { + let mut art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + art.opacity = opacity; + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let result = replace_unread(&mut doc, &frame, &[(id, vec!["placed files".into()])], &[], &BTreeSet::new(), &mut BTreeSet::new()); + assert_eq!(result.unwrap_err(), "an unread object's appearance is not identifiable in the page art"); + } + + /// A claimed first box lets a second unread object use its own corner box. + #[test] + fn second_unread_object_tries_corner_after_first_box_is_claimed() { + let mut doc = Document::new(100.0, 100.0); + let first = doc.alloc_id(); + let second = doc.alloc_id(); + for (id, bounds) in [(first, Rect::new(10.0, 10.0, 20.0, 20.0)), (second, Rect::new(12.0, 12.0, 22.0, 22.0))] { + let node = Node::path(id, vectorcraft_geom::shapes::rectangle(bounds), Default::default()); + doc.insert(Some(doc.layers[0].id), doc.layers[0].children().unwrap().len(), node).unwrap(); + } + + let mut page = Document::new(100.0, 100.0); + for bounds in [Rect::new(14.0, 14.0, 16.0, 16.0), Rect::new(51.0, 51.0, 59.0, 59.0)] { + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let unread = [(first, vec!["placed files".into()]), (second, vec!["placed files".into()])]; + let mut one_box_doc = doc.clone(); + let kinds = replace_unread(&mut doc, &frame, &unread, &[(second, Rect::new(50.0, 50.0, 60.0, 60.0))], &BTreeSet::new(), &mut BTreeSet::new()) + .unwrap(); + assert_eq!(kinds, ["placed files (2)"]); + let first_art = doc.node(first).unwrap().children().unwrap()[0].geometric_bounds(); + let second_art = doc.node(second).unwrap().children().unwrap()[0].geometric_bounds(); + assert_eq!(first_art, Some(Rect::new(14.0, 14.0, 16.0, 16.0))); + assert_eq!(second_art, Some(Rect::new(51.0, 51.0, 59.0, 59.0))); + assert_eq!( + replace_unread(&mut one_box_doc, &frame, &unread, &[], &BTreeSet::new(), &mut BTreeSet::new()).unwrap_err(), + "one page object matches more than one unread object" + ); + } + + /// Only the page's type in the page art that replaces an unread object is taken out of the + /// page's type for the text objects: type in the object's other box, which isn't used, stays. + #[test] + fn only_page_type_in_the_chosen_box_is_taken_for_an_unread_object() { + let mut doc = Document::new(100.0, 100.0); + let id = doc.alloc_id(); + doc.insert( + Some(doc.layers[0].id), + 0, + Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), Default::default()), + ) + .unwrap(); + let mut page = Document::new(100.0, 100.0); + let mut nodes = vec![]; + for bounds in [Rect::new(12.0, 12.0, 18.0, 18.0), Rect::new(52.0, 52.0, 58.0, 58.0)] { + let art = Node::path( + page.alloc_id(), + vectorcraft_geom::shapes::rectangle(bounds), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + nodes.push(Arc::new(art.clone())); + page.insert(Some(page.layers[0].id), page.layers[0].children().unwrap().len(), art).unwrap(); + } + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let unread = [(id, vec!["placed files".into()])]; + let mut shown: Vec>> = nodes.iter().map(|n| vec![Arc::clone(n)]).collect(); + let taken = take_unread_type(&doc, Some(&frame), &unread, &[(id, Rect::new(50.0, 50.0, 60.0, 60.0))], &mut shown); + assert_eq!(taken.iter().flatten().map(|n| n.id).collect::>(), [nodes[0].id]); + assert_eq!(shown.iter().flatten().map(|n| n.id).collect::>(), [nodes[1].id]); + let mut copied = BTreeSet::new(); + replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap(); + assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id)); + } +} diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index e6dc909ac..188a1b6e6 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -24,6 +24,7 @@ pub(super) const LAYERS_UNREAD: &str = "the file's layers weren't read"; pub fn is_loss(note: &str) -> bool { note.ends_with(TEXT_LEFT_OUT) || note.ends_with(TYPE_LEFT_OUT) + || note.ends_with("they came in as the page's art") || note == PAGE_TYPE_LEFT_OUT || note == PAGE_TYPE_UNCHECKED || [ART_LEFT_OUT, LAYERS_UNREAD, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 29e73bbc9..9ef30473e 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -53,6 +53,10 @@ fn square(x: u32, y: u32) -> String { format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4) } +fn square_path(x: u32, y: u32) -> String { + format!("{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4) +} + /// An EPS whose page draws `page` and whose private data is `native`. fn eps(page: &str, native: &str) -> Vec { let stream = ascii85(&zstd(native)); @@ -258,6 +262,667 @@ fn pdf_part(x: f64, y: f64) -> Document { d } +fn pdf_two_squares() -> Document { + let mut page = pdf_part(10.0, 86.0); + let other = pdf_part(50.0, 86.0); + let layer = page.layers[0].id; + let mut square = page.reid(&other.layers[0].children().unwrap()[0]); + square.appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid( + vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0), + )))], + ..Default::default() + } + .into(); + page.insert(Some(layer), 1, square).unwrap(); + page +} + +fn pdf_three_squares() -> Document { + let mut page = pdf_two_squares(); + let other = pdf_part(70.0, 86.0); + let layer = page.layers[0].id; + let square = page.reid(&other.layers[0].children().unwrap()[0]); + page.insert(Some(layer), 2, square).unwrap(); + page +} + +fn pdf_split_square() -> Document { + let mut page = pdf_part(10.0, 86.0); + let layer = page.layers[0].id; + for (i, color) in [vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0), vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)].into_iter().enumerate() + { + let x = 50.0 + 2.0 * i as f64; + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 86.0, x + 2.0, 90.0)); + let appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(color)))], + ..Default::default() + }; + let node = Node::path(page.alloc_id(), path, appearance); + page.insert(Some(layer), i + 1, node).unwrap(); + } + page +} + +fn os1_group(body: &str) -> String { + format!("u\n{body}\nU\n") +} + +fn replacement_is_cyan(doc: &Document, layer: usize, child: usize) { + let group = &doc.layers[layer].children().unwrap()[child]; + let art = &group.children().unwrap()[0]; + assert!(matches!(art.kind, NodeKind::Path { .. })); + assert!(matches!(art.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill)) + if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0)))); +} + +fn replacement_has_two_halves(doc: &Document) { + let group = &doc.layers[1].children().unwrap()[0]; + let art = group.children().unwrap(); + assert_eq!(art.len(), 2); + assert_eq!(art[0].geometric_bounds().unwrap().x0, 50.0); + assert_eq!(art[1].geometric_bounds().unwrap().x0, 52.0); + assert!(matches!(art[0].appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill)) + if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0)))); + assert!(matches!(art[1].appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill)) + if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)))); +} + +#[test] +fn top_level_symbol_uses_page_art_beside_live_path() { + let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0)); + let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;"; + let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{instance}\n{}", square(70, 10))))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("symbols (1)")), "{notes:?}"); +} + +#[test] +fn group_with_only_symbol_instance_uses_page_art() { + let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0)); + let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;"; + let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(instance)))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("symbols (1)")), "{notes:?}"); +} + +#[test] +fn unread_group_uses_page_opacity_once() { + let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0)); + let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;"; + let group = format!("0 Ae\nu\n{instance}\nU\n0 0.5 0 0 0 Xy\n0 0 Xd\n6 () XW"); + let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &group))); + let mut page = pdf_two_squares(); + let id = page.layers[0].children().unwrap()[1].id; + page.node_mut(id).unwrap().opacity = 0.5; + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + let replacement = &doc.layers[1].children().unwrap()[0]; + assert_eq!(replacement.opacity, 1.0); + assert_eq!(replacement.children().unwrap()[0].opacity, 0.5); + let rendered = vectorcraft_render::Renderer::new().render_region(&doc, vectorcraft_geom::Rect::new(50.0, 86.0, 54.0, 90.0), 4.0, false); + let pixel = rendered.pixels.as_chunks::<4>().0.get(8 * rendered.width as usize + 8).unwrap(); + assert!((120..=136).contains(&pixel[3]), "the final document draws the group at 50% once: {pixel:?}"); +} + +#[test] +fn unread_group_with_unidentified_transparency_uses_page_document() { + let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0)); + let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;"; + let group = format!("0 Ae\nu\n{instance}\nU\n0 0.5 0 0 0 Xy\n0 0 Xd\n6 () XW"); + let art = native(&(symbol + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &group))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("appearance is not identifiable")), "{notes:?}"); +} + +#[test] +fn group_with_only_placed_file_without_preview_uses_page_art() { + let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace"; + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(placed)))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("placed files (1)")), "{notes:?}"); +} + +#[test] +fn top_level_pattern_uses_page_art_beside_live_path() { + let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}\n{}", square_path(50, 10), square(70, 10)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("pattern fills (1)")), "{notes:?}"); +} + +#[test] +fn pattern_set_in_plain_group_replaces_later_sibling_from_page_art() { + let group = os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10))); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{group}\n{}", square_path(70, 10))))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]", "group[1]"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("pattern fills (2)")), "{notes:?}"); +} + +#[test] +fn top_level_placed_file_without_preview_uses_page_art() { + let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace"; + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{placed}\n{}", square(70, 10))))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("placed files (1)")), "{notes:?}"); +} + +#[test] +fn nested_previewless_placed_file_at_box_corner_falls_back() { + let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace"; + let art = native(&layer("Placed", true, &os1_group(place))).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); + let mut page = Document::new(100.0, 100.0); + let id = page.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); + let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + page.insert( + Some(page.layers[0].id), + 0, + Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }), + ) + .unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "a nested placeholder has no group-wide corner alternative: {notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); +} + +#[test] +fn group_with_previewless_placed_file_and_readable_sibling_keeps_sibling() { + let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace"; + let group = os1_group(&format!("{place}\n{}", square(70, 10))); + let art = native(&layer("Mixed", true, &group)).replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); + let mut page = pdf_part(70.0, 86.0); + let id = page.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); + let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + page.insert( + Some(page.layers[0].id), + 1, + Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }), + ) + .unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + if read { + let mixed = doc.layers.iter().find(|layer| layer.name.as_deref() == Some("Mixed")).unwrap(); + assert!( + mixed.children().unwrap().iter().any(|child| { + child.children().is_some_and(|children| children.iter().any(|n| n.geometric_bounds().is_some_and(|b| b.x0 == 70.0))) + }), + "readable sibling was dropped: {notes:?}" + ); + } else { + assert_eq!(names(&doc), ["Layer 1"], "{notes:?}"); + } +} + +#[test] +fn previewless_placed_file_tries_corner_when_first_box_contains_live_art() { + let place = "%AI5_BeginPlace\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(missing.png)`\n~\n%AI5_EndPlace"; + let live = "0 0 1 0 k\n130 -180 m\n134 -180 L\n134 -184 L\n130 -184 L\nf"; + let art = native(&(layer("Live", true, live) + &layer("Placed", true, place))) + .replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); + let mut page = Document::new(100.0, 100.0); + let id = page.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); + let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + page.insert( + Some(page.layers[0].id), + 0, + Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }), + ) + .unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Placed"]); + assert_eq!(kinds(&doc.layers[0]), ["path"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]"]); + assert!(notes.iter().any(|note| note.contains("placed files (1)")), "{notes:?}"); +} + +#[test] +fn replaced_page_art_reids_opacity_mask_art() { + let place = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace"; + let art = native(&layer("Placed", true, place)); + let mut page = pdf_part(50.0, 86.0); + let page_node_id = page.layers[0].children().unwrap()[0].id; + let mask_id = page.alloc_id(); + let mask_path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 86.0, 54.0, 90.0)); + let mask_fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE)); + let mask_art = Node::path( + mask_id, + mask_path, + vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(mask_fill)], ..Default::default() }, + ); + page.node_mut(page_node_id).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true))); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let replacement = &doc.layers[0].children().unwrap()[0].children().unwrap()[0]; + let mask = replacement.extras().mask.as_ref().unwrap(); + assert_ne!(mask.art.id, mask_id); + assert_ne!(mask.art.id, replacement.id); + assert!(mask.art.id.0 < doc.peek_next_id()); +} + +#[test] +fn top_level_placed_file_replacement_keeps_page_image_data() { + use vectorcraft_doc::{ImageBlob, ImageObject}; + + let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace"; + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, placed))); + let mut page = pdf_part(10.0, 86.0); + let pixels = image::RgbaImage::from_pixel(4, 4, image::Rgba([0, 255, 255, 255])); + let mut png = Vec::new(); + pixels.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 expected = blob.bytes.clone(); + page.images.insert(key.clone(), blob); + let image = Node::new( + page.alloc_id(), + NodeKind::Image(Box::new(ImageObject { + key: key.clone(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(50.0, 86.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + page.insert(Some(page.layers[0].id), 1, image).unwrap(); + + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + let replacement = &doc.layers[1].children().unwrap()[0]; + let [image] = replacement.children().unwrap().as_slice() else { panic!("{replacement:?}") }; + let NodeKind::Image(im) = &image.kind else { panic!("{image:?}") }; + assert!(im.pixels.is_none(), "PDF-imported images use document data"); + assert_eq!(doc.images.get(&im.key).map(|blob| &blob.bytes), Some(&expected)); + assert!(notes.iter().any(|note| note.contains("placed files (1)")), "{notes:?}"); +} + +#[test] +fn top_level_pattern_replacement_keeps_gradient_stop_swatch() { + use vectorcraft_color::gradient::{GradientKind, GradientPaint, GradientStop}; + use vectorcraft_color::{Color, Gradient, Paint, Swatch}; + + let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body))); + let mut page = pdf_two_squares(); + let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0); + page.swatches.push(Swatch { name: "Linked Cyan".into(), paint: Paint::solid(cyan), global: true, spot: false }); + let mut stop = GradientStop::new(0.0, cyan); + stop.swatch = Some("Linked Cyan".into()); + let gradient = Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, cyan)]); + let id = page.layers[0].children().unwrap()[1].id; + let node = page.node_mut(id).unwrap(); + node.appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(gradient)))))], + ..Default::default() + } + .into(); + + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let group = &doc.layers[1].children().unwrap()[0]; + let replacement = &group.children().unwrap()[0]; + assert!(matches!(replacement.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(g)) + if g.gradient.stops[0].swatch.as_deref() == Some("Linked Cyan"))); + assert!(matches!(doc.swatch("Linked Cyan"), Some(s) if s.global && s.paint == Paint::solid(cyan))); +} + +/// Two independently imported copies of the same tile have different node IDs. +fn page_with_named_pattern(native_art: &str, different: bool) -> Document { + use vectorcraft_color::Paint; + + let (editing, _) = crate::ai_alone(&private(&native_art.replace("(Absent)", "(Dots)"))).unwrap(); + let source = editing.pattern("Dots").unwrap(); + let mut page = pdf_two_squares(); + let mut pattern = source.clone(); + pattern.art = source.art.iter().map(|node| std::sync::Arc::new(page.reid(node))).collect(); + if different { + pattern.tile.x1 += 1.0; + } + page.patterns.push(pattern); + let id = page.layers[0].children().unwrap()[1].id; + let node = page.node_mut(id).unwrap(); + node.appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Pattern { + pattern: "Dots".into(), + xf: vectorcraft_geom::Affine::IDENTITY, + }))], + ..Default::default() + } + .into(); + page +} + +fn named_pattern_replacement_art() -> String { + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let unread = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10)); + native(&format!("{dots}{}{}", layer("Live", true, &square(10, 10)), layer("Mixed", true, &unread))) +} + +#[test] +fn same_named_pattern_in_page_art_keeps_editing_layers() { + let art = named_pattern_replacement_art(); + let page = page_with_named_pattern(&art, false); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(doc.patterns.iter().filter(|p| p.name == "Dots").count(), 1); + let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0]; + assert!(matches!(replacement.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots")); +} + +#[test] +fn copied_page_pattern_reids_its_mask_art() { + let source_art = named_pattern_replacement_art(); + let mut page = page_with_named_pattern(&source_art, false); + let mask_id = page.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0)); + let fill = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE)); + let mask_art = + Node::path(mask_id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(fill)], ..Default::default() }); + std::sync::Arc::make_mut(&mut page.patterns[0].art[0]).extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true))); + let art = native(&format!( + "{}{}", + layer("Live", true, &square(10, 10)), + layer("Mixed", true, &format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10))) + )); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let mask = doc.pattern("Dots").unwrap().art[0].extras().mask.as_ref().unwrap(); + assert_ne!(mask.art.id, mask_id); + assert!(mask.art.id.0 < doc.peek_next_id()); +} + +#[test] +fn different_named_pattern_in_page_art_falls_back() { + let art = named_pattern_replacement_art(); + let page = page_with_named_pattern(&art, true); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("page art conflicts with pattern Dots")), "{notes:?}"); +} + +#[test] +fn partly_overlapping_page_art_does_not_replace_an_object() { + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10)))))); + let mut page = pdf_part(10.0, 86.0); + let other = pdf_part(48.0, 86.0); + let id = page.layers[0].id; + let square = page.reid(&other.layers[0].children().unwrap()[0]); + page.insert(Some(id), 1, square).unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}"); +} + +/// Page art for the unread square and its neighbor under one PDF group. +fn grouped_page_across_unread_and_neighbor(clipped: bool) -> Document { + let mut page = pdf_two_squares(); + let layer = page.layers[0].id; + let unread = page.remove(page.layers[0].children().unwrap()[1].id).unwrap(); + let other = pdf_part(70.0, 86.0); + let neighbor = page.reid(&other.layers[0].children().unwrap()[0]); + let mut children = Vec::new(); + if clipped { + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(50.0, 86.0, 74.0, 90.0)); + let mut clip = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + children.push(std::sync::Arc::new(clip)); + } + children.extend([unread, std::sync::Arc::new(neighbor)]); + let mut group = Node::group(page.alloc_id(), children); + group.set_clips(clipped); + page.insert(Some(layer), 1, group).unwrap(); + page +} + +#[test] +fn clipped_pdf_group_across_unread_and_neighbor_falls_back() { + let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), square(70, 10)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body))); + let (doc, notes, read) = crate::layered_ai(&private(&art), grouped_page_across_unread_and_neighbor(true), vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("clipping or appearance ancestor")), "{notes:?}"); +} + +#[test] +fn plain_pdf_group_across_unread_and_neighbor_uses_child() { + let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), square(70, 10)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body))); + let (doc, notes, read) = crate::layered_ai(&private(&art), grouped_page_across_unread_and_neighbor(false), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("`ZZ` (1)")), "{notes:?}"); +} + +#[test] +fn live_art_inside_unread_bounds_falls_back_instead_of_drawing_twice() { + let body = format!("{}\n{}", os1_group(&format!("{}\nZZ", square(50, 10))), "0 0 1 0 k\n51 11 m\n53 11 L\n53 13 L\n51 13 L\nf"); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body))); + let mut page = pdf_two_squares(); + let inner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(51.0, 87.0, 53.0, 89.0)); + let appearance = (*page.layers[0].children().unwrap()[0].appearance).clone(); + let node = Node::path(page.alloc_id(), inner, appearance); + let layer_id = page.layers[0].id; + page.insert(Some(layer_id), 2, node).unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("bounds also contain live art")), "{notes:?}"); +} + +#[test] +fn live_art_crossing_unread_bounds_by_half_a_point_falls_back() { + let neighbor = "0 0 1 0 k\n53.5 11 m\n54.5 11 L\n54.5 13 L\n53.5 13 L\nf"; + let art = native(&layer("Mixed", true, &format!("{}\n{neighbor}", os1_group(&format!("{}\nZZ", square(50, 10)))))); + let mut page = pdf_part(50.0, 86.0); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(53.5, 87.0, 54.5, 89.0)); + let appearance = (*page.layers[0].children().unwrap()[0].appearance).clone(); + let node = Node::path(page.alloc_id(), path, appearance); + let layer = page.layers[0].id; + page.insert(Some(layer), 1, node).unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("bounds also contain live art")), "{notes:?}"); +} + +#[test] +fn overlapping_unread_objects_cannot_claim_the_same_page_art() { + let first = os1_group(&format!("{}\nZZ", square(50, 10))); + let second = os1_group(&format!("{}\nZZ", square(52, 10))); + let art = native(&layer("Mixed", true, &format!("{first}\n{second}"))); + let mut page = pdf_part(52.0, 86.0); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(52.0, 86.0, 54.0, 90.0)); + let node = page.layers[0].children().unwrap()[0].as_ref().clone(); + let layer = page.layers[0].id; + page.remove(node.id).unwrap(); + let replacement = Node::path(page.alloc_id(), path, (*node.appearance).clone()); + page.insert(Some(layer), 0, replacement).unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("more than one unread object")), "{notes:?}"); +} + +#[test] +fn top_level_pattern_without_page_art_falls_back() { + let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}"); +} + +#[test] +fn top_level_placed_file_without_page_art_falls_back() { + let placed = "%AI5_BeginPlace\n[1 0 0 1 0 0] 50 10 54 14 (missing) `\n~\n%AI5_EndPlace"; + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, placed))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}"); +} + +#[test] +fn unread_object_on_second_artboard_takes_second_page_art() { + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let second = "u\n0 0 1 0 k\n50 -100 m\n54 -100 L\n54 -96 L\n50 -96 L\nf\nZZ\nU"; + let art = native(&(boards.to_string() + &layer("Live", true, &square(10, 10)) + &layer("Mixed", true, second))); + let mut page = pdf_part(10.0, 86.0); + let mut board = page.artboards[0].clone(); + (board.id, board.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + page.artboards.push(board); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(170.0, 46.0, 174.0, 50.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0)); + let appearance = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint))], + ..Default::default() + }; + let node = Node::path(page.alloc_id(), path, appearance); + let layer = page.layers[0].id; + page.insert(Some(layer), 1, node).unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + replacement_is_cyan(&doc, 1, 0); + let bounds = doc.layers[1].children().unwrap()[0].visual_bounds().unwrap(); + assert_eq!(bounds, vectorcraft_geom::Rect::new(50.0, 196.0, 54.0, 200.0)); +} + +#[test] +fn unknown_operator_between_objects_falls_back() { + let body = format!("{}\nZZ\n{}", square(50, 10), square(70, 10)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &body))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("`ZZ`")), "{notes:?}"); +} + +#[test] +fn unread_object_without_page_art_names_what_is_unread() { + let art = native(&layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10))))); + let e = crate::ai_alone(&private(&art)).unwrap_err(); + assert_eq!(e, "it has operators VectorCraft doesn't know (`ZZ`), which VectorCraft doesn't read from it yet"); + let pattern = os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(70, 10))); + let art = native(&layer("Mixed", true, &format!("{}\n{pattern}", os1_group(&format!("{}\nZZ", square(50, 10)))))); + let e = crate::ai_alone(&private(&art)).unwrap_err(); + assert_eq!(e, "it has operators VectorCraft doesn't know (`ZZ`) and pattern fills, which VectorCraft doesn't read from it yet"); +} + +#[test] +fn unread_pattern_inside_one_object_uses_page_art_and_keeps_layers() { + let art = native( + &(layer("Live", true, &square(10, 10)) + + &layer( + "Mixed", + true, + &format!("{}\n{}", os1_group(&format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}", square_path(50, 10))), square(70, 10)), + )), + ); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[0]), ["path"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]); + replacement_is_cyan(&doc, 1, 0); + assert!(notes.iter().any(|n| n.contains("pattern fills (1)") && n.contains("page's art")), "{notes:?}"); +} + +#[test] +fn unknown_operator_inside_group_uses_page_art() { + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10)))))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_split_square(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Mixed"]); + assert_eq!(kinds(&doc.layers[1]), ["group[2]"]); + replacement_has_two_halves(&doc); + assert!(notes.iter().any(|n| n.contains("`ZZ` (1)")), "{notes:?}"); +} + +#[test] +fn unread_object_on_hidden_layer_stays_left_out() { + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Hidden", false, &os1_group(&format!("{}\nZZ", square(50, 10)))))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Live", "Hidden"]); + assert!(notes.iter().any(|n| n.contains("left out of them")), "{notes:?}"); +} + +#[test] +fn unread_object_without_page_art_falls_back() { + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10)))))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}"); +} + +#[test] +fn unclosed_unread_group_falls_back() { + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("u\n{}\nZZ", square(50, 10))))); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("closes something it didn't open")), "{notes:?}"); +} + +#[test] +fn eps_unread_group_uses_printed_page_art() { + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &os1_group(&format!("{}\nZZ", square(50, 10)))))); + let page = format!( + "{}\n1 0 0 0 setcmykcolor 50 10 moveto 52 10 lineto 52 14 lineto 50 14 lineto closepath fill\n0 1 0 0 setcmykcolor 52 10 moveto 54 10 lineto 54 14 lineto 52 14 lineto closepath fill", + page_square(10, 10) + ); + let result = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&result.document), ["Live", "Mixed"], "{:?}", result.warnings); + replacement_has_two_halves(&result.document); + assert!(result.warnings.iter().any(|n| n.contains("`ZZ` (1)")), "{:?}", result.warnings); +} + /// The PDF part `d` as read in points from pages of `k` points a unit (their `UserUnit`): its /// pages and art `k` times as large. fn in_points(mut d: Document, k: f64) -> Document { @@ -1992,6 +2657,61 @@ fn threaded_area_type_flows_through_its_frames() { assert_eq!(r.document.text_threads, [ids]); } +/// Replacing a group containing either threaded frame must use the PDF page instead. +#[test] +fn unread_group_holding_threaded_frame_falls_back() { + let frames = [ + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 10.0, 54.0, 14.0)), + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(70.0, 10.0, 74.0, 14.0)), + ]; + let text = document_of(&frames, &[story("Threaded\\r", &[0, 1], "/0 0", "/1 10.0")]); + for removed in [0, 1] { + let grouped = os1_group(&format!("{}\n{}\nZZ", square(if removed == 0 { 50 } else { 70 }, 10), frame_object(0, removed))); + let other = frame_object(0, 1 - removed); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Mixed", true, &format!("{grouped}\n{other}"))), &text); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_three_squares(), vec![], false, true, 1.0); + assert!(!read, "removed frame {removed}: {notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("an unread object holds a frame of threaded type")), "removed frame {removed}: {notes:?}"); + } +} + +/// Replacing an unread group may still discard a single-frame text object. +#[test] +fn unread_group_holding_single_frame_uses_page_art() { + let frame = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 10.0, 54.0, 14.0)); + let text = document_of(&[frame], &[story("Single\\r", &[0], "/0 0", "/1 10.0")]); + let grouped = os1_group(&format!("{}\n{}\nZZ", square(50, 10), frame_object(0, 0))); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Mixed", true, &grouped)), &text); + let (doc, notes, read) = crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Back", "Mixed"]); + assert!(doc.text_threads.is_empty()); +} + +/// The page's type inside an unread object's box comes in once, with the page's art that replaces +/// the object, whether every text object's type is made from the text document or not. +#[test] +fn page_type_inside_an_unread_object_comes_in_with_its_page_art() { + let unread = os1_group("0 0 1 0 k\n20 10 m\n50 10 L\n50 30 L\n20 30 L\nf\nZZ"); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show \ + 1 0 0 0 setcmykcolor 20 10 moveto 50 10 lineto 50 30 lineto 20 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 8 scalefont setfont 22 14 moveto (Ex) show"; + let made = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let unmade = + last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8200.0, 8120.0), (0.0, 0.0), 0.0)])); + for (doc, objects) in [(made, text_object(0)), (unmade, text_object(0) + &text_object(1))] { + let art = native_with_text(&(layer("Words", true, &objects) + &layer("Mixed", true, &unread)), &doc); + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Words", "Mixed"], "{:?}", r.warnings); + let mut texts = all_texts(&r.document); + texts.retain(|t| !t.is_empty()); + assert_eq!(texts, ["Hi there", "Ex"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.starts_with("some of the page's type")), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("`ZZ` (1)")), "{:?}", r.warnings); + } +} + #[test] fn shown_type_the_page_draws_in_pieces_comes_whole_from_the_text_document() { // Type on a path the page draws letter by letter: the letters lie on the path, and only the diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f69921d5d..00d07dcbe 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5935,6 +5935,9 @@ breakText Glyphs Глифи Gap 4 Разстояние 4 Dash 5 Тире 5 Gap 5 Разстояние 5 + +@msg {_1}: they came in as the page's art {_1}: те са внесени като изображението от страницата + @msg You must choose a side pattern. Трябва да изберете страничен шаблон. @msg invalid pattern tile choice невалиден избор на плочка за шаблон @msg no pattern named `{choice}` няма шаблон с име `{choice}` diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 904856098..b49ece9ce 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5968,6 +5968,8 @@ shape Open Otvoren @msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b" @msg the paths have nothing to merge there putanje tu nemaju šta spojiti @msg the path is locked or hidden putanja je zaključana ili skrivena +@msg {_1}: they came in as the page's art {_1}: uvezeni su kao ilustracija stranice + @msg You must choose a side pattern. Morate odabrati bočni uzorak. @msg invalid pattern tile choice nevažeći izbor pločice uzorka @msg no pattern named `{choice}` nema uzorka pod nazivom `{choice}` diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2eadca7c1..5509d3eaf 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6039,6 +6039,9 @@ breakText Glyphs Glifs Gap 4 Separació 4 Dash 5 Guió 5 Gap 5 Separació 5 + +@msg {_1}: they came in as the page's art {_1}: han entrat com a art de la pàgina + @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible @msg You must choose a side pattern. Heu de triar un patró lateral. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d45c83723..6546f97ec 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6051,6 +6051,9 @@ breakText Glyphs Glyfy Gap 4 Mezera 4 Dash 5 Čárka 5 Gap 5 Mezera 5 + +@msg {_1}: they came in as the page's art {_1}: byly načteny jako kresba ze stránky + @msg You must choose a side pattern. Musíte zvolit boční vzorek. @msg invalid pattern tile choice neplatná volba dlaždice vzorku @msg no pattern named `{choice}` vzorek s názvem `{choice}` neexistuje diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 74d5e43d6..6ef8a029a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5950,6 +5950,9 @@ breakText Glyphs Glyffer Gap 4 Mellemrum 4 Dash 5 Streg 5 Gap 5 Mellemrum 5 + +@msg {_1}: they came in as the page's art {_1}: de blev importeret som sidens grafik + @msg You must choose a side pattern. Du skal vælge et sidemønster. @msg invalid pattern tile choice ugyldigt valg af mønsterflise @msg no pattern named `{choice}` intet mønster med navnet `{choice}` diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f1e17a4d3..c4d3b973d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5636,6 +5636,9 @@ breakText Glyphs Glyphen Gap 4 Lücke 4 Dash 5 Strich 5 Gap 5 Lücke 5 + +@msg {_1}: they came in as the page's art {_1}: sie wurden als Seitengrafik importiert + @msg You must choose a side pattern. Sie müssen ein Seitenmuster auswählen. @msg invalid pattern tile choice ungültige Auswahl einer Musterkachel @msg no pattern named `{choice}` kein Muster mit dem Namen `{choice}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9c046d74f..89e694f67 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6003,6 +6003,9 @@ breakText Glyphs Γλύφοι Gap 4 Διάκενο 4 Dash 5 Παύλα 5 Gap 5 Διάκενο 5 + +@msg {_1}: they came in as the page's art {_1}: εισήχθησαν ως γραφικά της σελίδας + @msg You must choose a side pattern. Πρέπει να επιλέξετε πλευρικό μοτίβο. @msg invalid pattern tile choice μη έγκυρη επιλογή πλακιδίου μοτίβου @msg no pattern named `{choice}` δεν υπάρχει μοτίβο με όνομα `{choice}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index eaa3f66d7..f4ada77f1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5993,6 +5993,9 @@ breakText Glyphs Glifos Gap 4 Hueco 4 Dash 5 Guion 5 Gap 5 Hueco 5 + +@msg {_1}: they came in as the page's art {_1}: se importaron como el arte de la página + @msg You must choose a side pattern. Debe elegir un motivo lateral. @msg invalid pattern tile choice selección de mosaico de motivo no válida @msg no pattern named `{choice}` no hay ningún motivo llamado `{choice}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a4c7a8774..1161c73de 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6081,6 +6081,9 @@ breakText Glyphs Merkit Gap 4 Väli 4 Dash 5 Viiva 5 Gap 5 Väli 5 + +@msg {_1}: they came in as the page's art {_1}: ne tuotiin sivun piirroksena + @msg You must choose a side pattern. Valitse sivukuvio. @msg invalid pattern tile choice virheellinen kuviolaatan valinta @msg no pattern named `{choice}` kuviota nimeltä `{choice}` ei löydy diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 774b000ba..e12387e1d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6048,6 +6048,9 @@ breakText Glyphs Glyphes Gap 4 Écart 4 Dash 5 Tiret 5 Gap 5 Écart 5 + +@msg {_1}: they came in as the page's art {_1} : ils ont été importés comme le dessin de la page + @msg You must choose a side pattern. Vous devez choisir un motif latéral. @msg invalid pattern tile choice choix de tuile de motif non valide @msg no pattern named `{choice}` aucun motif nommé `{choice}` diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 46bb0c991..c6fcdcb49 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6037,6 +6037,9 @@ breakText Glyphs Glifák Gap 4 Hézag 4 Dash 5 Vonal 5 Gap 5 Hézag 5 + +@msg {_1}: they came in as the page's art {_1}: az oldal rajzaként lettek importálva + @msg You must choose a side pattern. Válasszon oldalsó mintát. @msg invalid pattern tile choice érvénytelen mintacsempe-választás @msg no pattern named `{choice}` nincs `{choice}` nevű minta diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e87dd93a..9ed018f08 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5950,6 +5950,9 @@ breakText Glyphs Glif Gap 4 Jarak 4 Dash 5 Garis 5 Gap 5 Jarak 5 + +@msg {_1}: they came in as the page's art {_1}: diimpor sebagai gambar halaman + @msg You must choose a side pattern. Anda harus memilih pola sisi. @msg invalid pattern tile choice pilihan ubin pola tidak valid @msg no pattern named `{choice}` tidak ada pola bernama `{choice}` diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2cf474260..669a0e3c3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5670,6 +5670,9 @@ breakText Glyphs Glifi Gap 4 Spazio 4 Dash 5 Tratto 5 Gap 5 Spazio 5 + +@msg {_1}: they came in as the page's art {_1}: sono stati importati come elementi grafici della pagina + @msg You must choose a side pattern. Devi scegliere un motivo laterale. @msg invalid pattern tile choice scelta della tessera motivo non valida @msg no pattern named `{choice}` nessun motivo chiamato `{choice}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8cdb79a67..0a05ba0b9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5737,6 +5737,9 @@ breakText Glyphs 字形 Gap 4 間隔 4 Dash 5 線分 5 Gap 5 間隔 5 + +@msg {_1}: they came in as the page's art {_1}: ページのアートとして読み込みました + @msg You must choose a side pattern. 側面パターンを選択してください。 @msg invalid pattern tile choice パターンタイルの選択が無効です @msg no pattern named `{choice}` `{choice}` というパターンはありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 22894ea3b..dcfc8a20f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6040,6 +6040,9 @@ breakText Glyphs გლიფები Gap 4 შუალედი 4 Dash 5 ტირე 5 Gap 5 შუალედი 5 + +@msg {_1}: they came in as the page's art {_1}: იმპორტირებულია გვერდის გამოსახულების სახით + @msg You must choose a side pattern. აირჩიეთ გვერდითი ნიმუში. @msg invalid pattern tile choice ნიმუშის ფილა არასწორად არის არჩეული @msg no pattern named `{choice}` ნიმუში სახელით `{choice}` არ არსებობს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9c9d8dbd3..1b7190a41 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5633,6 +5633,9 @@ breakText Glyphs 글리프 Gap 4 간격 4 Dash 5 대시 5 Gap 5 간격 5 + +@msg {_1}: they came in as the page's art {_1}: 페이지의 아트로 가져왔습니다 + @msg You must choose a side pattern. 측면 패턴을 선택해야 합니다. @msg invalid pattern tile choice 잘못된 패턴 타일 선택 @msg no pattern named `{choice}` `{choice}` 이름의 패턴이 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4918f150f..7d137e3e7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5994,6 +5994,9 @@ breakText Glyphs Glyphs Gap 4 Tussenruimte 4 Dash 5 Streepje 5 Gap 5 Tussenruimte 5 + +@msg {_1}: they came in as the page's art {_1}: ze zijn geïmporteerd als pagina-afbeelding + @msg You must choose a side pattern. Kies een zijpatroon. @msg invalid pattern tile choice ongeldige keuze voor patroontegel @msg no pattern named `{choice}` geen patroon met de naam `{choice}` diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d9e8ba9d..810f51bdf 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5906,6 +5906,9 @@ breakText Glyphs Glify Gap 4 Odstęp 4 Dash 5 Kreska 5 Gap 5 Odstęp 5 + +@msg {_1}: they came in as the page's art {_1}: zaimportowano je jako grafikę strony + @msg You must choose a side pattern. Musisz wybrać wzór boczny. @msg invalid pattern tile choice nieprawidłowy wybór kafelka wzoru @msg no pattern named `{choice}` nie ma wzoru o nazwie `{choice}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7aaabd1d..c5fe48efa 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5970,6 +5970,9 @@ breakText Glyphs Glifos Gap 4 Lacuna 4 Dash 5 Traço 5 Gap 5 Lacuna 5 + +@msg {_1}: they came in as the page's art {_1}: foram importados como arte da página + @msg You must choose a side pattern. Você deve escolher um padrão lateral. @msg invalid pattern tile choice escolha inválida de ladrilho de padrão @msg no pattern named `{choice}` não há padrão chamado `{choice}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d9d95ae35..dc410120f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6028,6 +6028,9 @@ breakText Glyphs Глифы Gap 4 Зазор 4 Dash 5 Штрих 5 Gap 5 Зазор 5 + +@msg {_1}: they came in as the page's art {_1}: импортированы как графика страницы + @msg You must choose a side pattern. Выберите боковой узор. @msg invalid pattern tile choice недопустимый выбор плитки узора @msg no pattern named `{choice}` узор с именем `{choice}` не найден diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e378d833f..a9b8a41e7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6103,6 +6103,9 @@ breakText Glyphs Glyfer Gap 4 Mellanrum 4 Dash 5 Streck 5 Gap 5 Mellanrum 5 + +@msg {_1}: they came in as the page's art {_1}: de importerades som sidans bild + @msg You must choose a side pattern. Du måste välja ett sidomönster. @msg invalid pattern tile choice ogiltigt val av mönsterplatta @msg no pattern named `{choice}` inget mönster med namnet `{choice}` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 981f2a3ec..379c377a2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6008,6 +6008,9 @@ breakText Glyphs Glifler Gap 4 Boşluk 4 Dash 5 Çizgi 5 Gap 5 Boşluk 5 + +@msg {_1}: they came in as the page's art {_1}: sayfanın çizimi olarak içe aktarıldılar + @msg You must choose a side pattern. Bir yan desen seçmelisiniz. @msg invalid pattern tile choice geçersiz desen döşemesi seçimi @msg no pattern named `{choice}` `{choice}` adlı desen yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4b4bda6bc..77eeeecc3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5650,6 +5650,9 @@ breakText Glyphs Гліфи Gap 4 Проміжок 4 Dash 5 Штрих 5 Gap 5 Проміжок 5 + +@msg {_1}: they came in as the page's art {_1}: їх імпортовано як графіку сторінки + @msg You must choose a side pattern. Виберіть бічний візерунок. @msg invalid pattern tile choice недійсний вибір плитки візерунка @msg no pattern named `{choice}` візерунка з назвою `{choice}` немає diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6b8c54330..a947f674d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5653,6 +5653,9 @@ breakText Glyphs Glyph Gap 4 Khoảng hở 4 Dash 5 Nét 5 Gap 5 Khoảng hở 5 + +@msg {_1}: they came in as the page's art {_1}: đã được nhập dưới dạng hình vẽ của trang + @msg You must choose a side pattern. Bạn phải chọn mẫu bên. @msg invalid pattern tile choice lựa chọn ô mẫu không hợp lệ @msg no pattern named `{choice}` không có mẫu tên `{choice}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a9d0563da..3fd35f967 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5893,6 +5893,9 @@ breakText Glyphs 字形 Gap 4 间隔 4 Dash 5 虚线 5 Gap 5 间隔 5 + +@msg {_1}: they came in as the page's art {_1}:已作为页面图稿导入 + @msg You must choose a side pattern. 必须选择侧边图案。 @msg invalid pattern tile choice 图案拼块选项无效 @msg no pattern named `{choice}` 没有名为 `{choice}` 的图案 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ceec87f9d..cd01899a4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5690,6 +5690,9 @@ breakText Glyphs 字符 Gap 4 間隙 4 Dash 5 虛線 5 Gap 5 間隙 5 + +@msg {_1}: they came in as the page's art {_1}:已作為頁面圖稿匯入 + @msg You must choose a side pattern. 必須選擇側邊圖樣。 @msg invalid pattern tile choice 圖樣拼塊選項無效 @msg no pattern named `{choice}` 沒有名為 `{choice}` 的圖樣 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a3858ef8e..2ca9181e3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c5588ef6d..52c925816 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1066,6 +1066,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). + A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).