mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-03 06:30:00 -06:00
.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.
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -68,6 +68,8 @@ pub(super) struct Obj {
|
||||
pub content: Vec<V>,
|
||||
/// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary.
|
||||
pub art: Option<std::sync::Arc<vectorcraft_doc::Node>>,
|
||||
/// What that art has that isn't read, reported once the mask is known to be enabled.
|
||||
pub unread: Vec<String>,
|
||||
}
|
||||
|
||||
impl Obj {
|
||||
|
||||
@@ -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<String>,
|
||||
/// Top-level object ids and the unread kinds within them.
|
||||
pub unread: Vec<(NodeId, Vec<String>)>,
|
||||
/// 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<NodeId, String>,
|
||||
/// 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<String>,
|
||||
/// What layers that don't show have that this reader doesn't read.
|
||||
hidden_unread: BTreeSet<String>,
|
||||
unread: Vec<(NodeId, Vec<String>)>,
|
||||
symbol_bounds: HashMap<String, Rect>,
|
||||
/// 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<Rect>,
|
||||
/// 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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
|
||||
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
|
||||
if matches!(kind, Kind::Group)
|
||||
&& 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<Node>, Vec<Node>) = 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<Option<NodeId>, 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`]).
|
||||
|
||||
@@ -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<V>) -> Vec<V> {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize>,
|
||||
/// 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<String>,
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
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::<String>();
|
||||
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\
|
||||
|
||||
@@ -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<f64> = 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");
|
||||
|
||||
@@ -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>), 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<Rect> = 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<Node>]| {
|
||||
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<NodeId> =
|
||||
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<Arc<Node>>| !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<Node>,
|
||||
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<NodeId>, live: &mut Vec<Rect>) {
|
||||
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<Node>, pages: &[Rect], moves: &[Affine], art: &mut Vec<PageArt>, 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<String>, patterns: &mut BTreeSet<String>, swatches: &mut BTreeSet<String>) {
|
||||
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>)]) -> 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<String> = 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<String>)],
|
||||
elsewhere: &[(NodeId, Rect)],
|
||||
shown: &mut Vec<Vec<Arc<Node>>>,
|
||||
) -> Vec<Vec<Arc<Node>>> {
|
||||
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<String>)],
|
||||
elsewhere: &[(NodeId, Rect)],
|
||||
threaded_frames: &BTreeSet<NodeId>,
|
||||
copied: &mut BTreeSet<NodeId>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
fn holds_threaded_frame(node: &Node, frames: &BTreeSet<NodeId>) -> 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<NodeId>) {
|
||||
ids.insert(node.id);
|
||||
for child in node.children().into_iter().flatten() {
|
||||
subtree(child, ids);
|
||||
}
|
||||
}
|
||||
|
||||
let unread_ids: BTreeSet<NodeId> = 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<Rect> = frame.pages.iter().map(|(page, _)| *page).collect();
|
||||
let moves: Vec<Affine> = frame.pages.iter().map(|(_, shift)| *shift).collect();
|
||||
let destination_pages: Vec<Rect> = 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<usize> = (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::<usize, usize>::new();
|
||||
let mut counts = std::collections::BTreeMap::<String, usize>::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<usize> =
|
||||
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::<Vec<_>>())
|
||||
{
|
||||
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::<Vec<_>>())?;
|
||||
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<Document> {
|
||||
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<Vec<Arc<Node>>> = 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::<Vec<_>>(), [nodes[0].id]);
|
||||
assert_eq!(shown.iter().flatten().map(|n| n.id).collect::<Vec<_>>(), [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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<u8> {
|
||||
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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 164 and column 5.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2190 and column 5.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}` というパターンはありません
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}` არ არსებობს
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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}` 이름의 패턴이 없습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}` не найден
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -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}` немає
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 178 and column 5.
|
@@ -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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 200 and column 5.
|
@@ -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}` 的图案
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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}` 的圖樣
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
File diff suppressed because one or more lines are too long
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user