.ai import: placed PDF and .ai pages open as placed documents

Squashed from #6083 onto main.
This commit is contained in:
dotCooCoo
2026-11-09 00:26:34 +07:00
committed by bflatastic
parent 434c032f9e
commit a409f34ebe
15 changed files with 609 additions and 18 deletions
+9 -4
View File
@@ -849,9 +849,10 @@ impl Resolved {
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
/// again (their images had the preview), modified ones are read again when `update`, links found
/// away from their path take the new path. A link nothing is known of whose image shows a preview
/// (a file another app wrote kept only that) was never read: its file is read now, into the
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
/// away from their path take the new path. A link nothing is known of whose image, or placed
/// document, shows a preview (a file another app wrote kept only that) was never read: its file is
/// read now, into the object's bounds, which isn't a change to report. A placed document whose
/// file's bytes are gone isn't showing a preview. Nothing here is an undo step.
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
let mut out = Resolved::default();
// The image (with its preview) and hash of each file read for a preview here, by path: images
@@ -863,7 +864,11 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![];
for g in groups(d, None) {
let row = rows(std::iter::once(&g), doc_path);
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
let shows_preview = match g.document {
None => previewing(d, &g.key),
Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy),
};
let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview;
let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d);
match probe(&g, doc_path) {
(Status::Missing, _) => out.missing.extend(row),
+4 -2
View File
@@ -308,8 +308,10 @@ pub(crate) fn pdf_art(
read = Err(e);
}
let (mut doc, warnings, alone) = fileio::page_or_editing_data(src, bytes, &o, index, read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
if alone {
// Linked images show their files, found from its folder.
// Linked images show their files, found from its folder. Its placed documents can place this
// file again: one already being read further up, or past the most nested reads, keeps its links
// as stored, as a VectorCraft document's do.
if alone && let Some(_reading) = document::Reading::enter(bytes) {
super::links::resolve(&mut doc, path, false, false);
}
doc.drop_edit_modes();
+2 -2
View File
@@ -157,11 +157,11 @@ std::thread_local! {
}
/// A document's links being resolved ([`READING`]): the entry goes when this does.
struct Reading;
pub(super) struct Reading;
impl Reading {
/// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are.
fn enter(bytes: &[u8]) -> Option<Self> {
pub(super) fn enter(bytes: &[u8]) -> Option<Self> {
let hash = hash_bytes(bytes);
READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading)
}
@@ -474,6 +474,26 @@ fn update_links_on_open_reads_a_modified_file_again() {
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
/// A placed document whose link keeps no size or hash and whose file's bytes are gone shows no
/// preview: resolving its links reports the file as modified and leaves the object as it is.
#[test]
fn a_placed_document_without_its_bytes_or_a_hash_is_reported_not_read() {
let dir = Folder::new("placed-no-hash");
let src = dir.file("logo.vectorcraft");
source(&src, 100.0, 50.0, RED, 0);
let mut s = session();
let id = place(&mut s, &src);
let key = placed(&s, id).key;
let mut d = (*s.doc().unwrap().doc).clone();
d.images.remove(&key);
let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("a placed document") };
p.link.hash = None;
p.link.size = None;
let r = crate::cmd::links::resolve(&mut d, None, false, false).to_json();
assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}");
assert!(!d.images.contains_key(&key), "not read");
}
#[test]
fn relink_points_a_placed_document_at_another_file() {
let dir = Folder::new("placed-relink");
@@ -663,6 +683,78 @@ fn saving_and_reopening_keeps_a_linked_pdf_pages_number_and_box() {
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// A placed PDF page in a `.ai` file's editing data is a placed document of the page and box the
/// dictionary after it names. Its file missing, it shows the preview the file keeps, in the same
/// place. Its file found in the folder kept relative to the document, it shows that page.
#[test]
fn a_pdf_page_placed_in_an_ai_files_editing_data_opens_as_a_placed_document() {
use vectorcraft_testkit::ai;
let dir = Folder::new("placed-pdf-in-ai");
let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n(/no/such/folder/src/flyer.pdf)`\n\
%%IncludeFile:/no/such/folder/src/flyer.pdf\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\
%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\
2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\
%_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n";
let doc_path = dir.file("poster.ai");
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place));
// The page draws what the half-covering preview draws over white.
write(&doc_path, &ai::ai(data.as_bytes(), "0.54 0.54 0.95 rg 20 20 60 60 re f"));
let shown = |s: &Session| {
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
let [r, g, b, _] = img.pixel(50, 50);
[r, g, b]
};
let one = |s: &Session| {
let mut found = vec![];
s.doc().unwrap().doc.visit_placed(|n, p| found.push((n.id, p.clone())));
let [(id, p)] = &found[..] else { panic!("one placed document: {found:?}") };
(*id, p.clone(), s.doc().unwrap().doc.images[&p.key].is_proxy())
};
let mut s = session();
let r = open(&mut s, &doc_path);
assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("flyer.pdf"), "{r}");
let (id, p, proxy) = one(&s);
assert!(proxy, "the preview");
assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false));
assert_eq!(p.link.relative.as_deref(), Some("src/flyer.pdf"));
let place = bounds(&s, id);
assert!((place.x0 - 20.0).abs() < 0.01 && (place.width() - 60.0).abs() < 0.01 && (place.height() - 60.0).abs() < 0.01, "{place:?}");
assert!(near(shown(&s), [138, 138, 243]), "the preview shows: {:?}", shown(&s));
pdf_source(&dir.file("src/flyer.pdf"), "pdf", &[BLUE, RED], 0);
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let (id, p, proxy) = one(&s);
assert!(!proxy, "the file's bytes, not the preview");
assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false));
assert!(p.link.hash.is_some() && p.link.size.is_some(), "{:?}", p.link);
assert_eq!(bounds(&s, id), place, "the same place");
assert!(near(shown(&s), RED), "page 2 of the file shows: {:?}", shown(&s));
}
/// A `.ai` saved without PDF compatibility (its page only text) that places itself as a page is
/// read once inside itself: the copy inside keeps its link as stored, and the file opens.
#[test]
fn an_ai_file_that_places_itself_opens() {
use vectorcraft_testkit::ai;
let dir = Folder::new("ai-places-itself");
let doc_path = dir.file("poster.ai");
let at = doc_path.replace('\\', "/");
let place = format!(
"%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n({at})`\n%%IncludeFile:{at}\n%AI26_BeginPlacedObjectPreview\n\
2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\
%_/ArtDictionary :\n%_1 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n"
);
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{place}", ai::rect(120.0, 20.0, 180.0, 80.0))));
write(&doc_path, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET"));
let mut s = session();
let r = open(&mut s, &doc_path);
let mut found = vec![];
s.doc().unwrap().doc.visit_placed(|_, p| found.push(p.link.path.replace('\\', "/")));
assert_eq!(found, [at], "{r}");
}
#[test]
fn a_pdf_compatible_ai_file_places_linked_like_a_pdf() {
let dir = Folder::new("placed-ai-linked");
+5 -1
View File
@@ -3572,7 +3572,8 @@ proptest! {
%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_0 g\n%_0 0 m\n%_5 0 l\n%_5 -5 l\n%_f\n%_X+\n%_ (Art) ,\n%_; (AI9_Mask) ,\n%_/XMLUID : (Photo) ; (AI10_ArtUID) ,\n%_;\n%_\n";
let place = "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -8000 8000] 8000 8000 8002 8001 4 4 0\n(/tmp/p.png)`\n%%IncludeFile:/tmp/p.png\n\
%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n()\n%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\
%AI17_Begin_Content_if_version_gt:24 5\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 @@\n%AI26_EndPlacedObjectPreview\n%AI17_End_Versioned_Content\n~\n%AI5_EndPlace\n";
%AI17_Begin_Content_if_version_gt:24 5\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 @@\n%AI26_EndPlacedObjectPreview\n%AI17_End_Versioned_Content\n~\n%AI5_EndPlace\n\
%_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n";
let text = ai::editing_data(100.0, 100.0, &ai::layer("L", &format!("{raster}{place}")));
let samples: &[u8] = &[255, 0, 0, 0, 0, 255, 128, 0];
let preview: &[u8] = &[255, 255, 0, 0, 0, 0, 0, 0];
@@ -4644,6 +4645,9 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"\n%AI26_EndPlacedObjectPreview\n",
"[1 0 0 -1 0 0] 0 0 2 1 4 4 0 (a.png) `",
"[1e308 0 0 1e308 0 0] 0 0 -1e308 1e308 4 4 0 (a.png) `",
"\n%_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n",
"\n%_/ArtDictionary :\n%_-1e308 /Real (AI11 PlacedPDFPageIndex) ,\n%_99 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n",
"\n%_/ArtDictionary :\n%_(2) /String (AI11 PlacedPDFPageIndex) ,\n%_5 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n",
"\n%AI28_BeginPlacedRelativePath\n",
"\n%AI28_BeginPlacedRelativePath\n(src/)\n%AI28_EndPlacedRelativePath\n",
"\n%AI28_BeginPlacedRelativePath\n(..\\\\..\\\\art\\\\)\n%AI28_EndPlacedRelativePath\n",
+2 -2
View File
@@ -41,8 +41,8 @@ pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
pub(crate) use frames::text_frames;
pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
pub use read::{
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette,
read, read_beside, variables,
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles,
palette, read, read_beside, variables,
};
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member};
+16 -3
View File
@@ -46,6 +46,10 @@
//! box is as large as the file has pixels; the matrix takes them into art space from the canvas
//! (y down, its centre the template box's), where they start at the point of art space's origin,
//! the first row the top one. Each is a linked image showing the preview until its file is found.
//! The dictionary after a placed PDF or `.ai` file has the page shown, from 1, in
//! `AI11 PlacedPDFPageIndex` and its box in `AI11 PlacedPDFCropTo` (0 Art, 1 Crop, 2 Trim, 3 Bleed,
//! 4 Media, 5 Bounding Box): each is a placed document of that page and box, showing the preview
//! until its file is found. A value outside those leaves it a linked image.
//! The version 7 form (§16) has no `%AI5_BeginPlace`: `[matrix] llx lly urx ury (path)` and
//! `` ` ``, then `%%IncludeFile:` or the file between `%%BeginDocument:` and `%%EndDocument`,
//! then `~`; its box is the file's `%%BoundingBox`. Newer files have three more numbers before
@@ -112,7 +116,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
LinkInfo, Node, NodeId, NodeKind, OpacityMask, PatternDef, StrokeLayer, Unit,
LinkInfo, Node, NodeId, NodeKind, OpacityMask, PageBox, PatternDef, StrokeLayer, Unit,
};
use vectorcraft_geom::{AnchorKind, FillRule, PathData};
@@ -139,10 +143,10 @@ pub use document::{
};
pub(crate) use document::{slot_frame, slot_of};
pub(crate) use ops::read_art_style;
pub use types::IMAGE_TOO_LARGE;
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead};
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
#[cfg(test)]
pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
pub use types::{IMAGE_TOO_LARGE, PlacedPage};
pub(super) use types::{blend_mode, blend_name};
#[path = "legacy_text.rs"]
@@ -185,6 +189,8 @@ pub struct Structure {
/// move, in its pixels, that places them there (the corner of the box they are written with,
/// in place of the canvas point of art space's origin).
pub placed_elsewhere: Vec<(NodeId, Affine)>,
/// The placed files that are pages of PDF or `.ai` files.
pub placed_pages: Vec<PlacedPage>,
/// The live envelopes read from the editing data, by id, with the drawn art each took the place
/// of: the importer puts that art back when the layers with the envelopes don't draw as the page.
pub envelopes_drawn: Vec<(NodeId, Node)>,
@@ -438,6 +444,9 @@ struct Reader<'a> {
image_link: Option<String>,
/// The placed file being read (`%AI5_BeginPlace` … `~`).
place: Option<Place>,
/// The placed files just read, until the dictionary after each: at most one for each depth of
/// frames, the innermost last.
placed_tails: Vec<PlacedTail>,
/// The last object read is the last child of the innermost frame, and nothing started since.
after_object: bool,
/// The last object read is an image, and only the operators that end its raster (`XH`, `Xr`,
@@ -554,6 +563,8 @@ struct Reader<'a> {
placeholders: Vec<Rect>,
/// See [`Structure::placed_elsewhere`].
placed_elsewhere: Vec<(NodeId, Affine)>,
/// See [`Structure::placed_pages`].
placed_pages: Vec<PlacedPage>,
unread_elsewhere: Vec<(NodeId, Rect)>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
@@ -732,6 +743,8 @@ impl<'a> Reader<'a> {
restyled: vec![],
placeholders: vec![],
placed_elsewhere: vec![],
placed_pages: vec![],
placed_tails: vec![],
unread_elsewhere: vec![],
mesh: None,
cmyk_cache: HashMap::new(),
+64 -3
View File
@@ -2,13 +2,14 @@ use super::containers::close;
use super::document::TEXT_SLOT;
use super::types::{
FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART,
NON_NATIVE_ART_UNREAD, Place,
NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail,
};
use super::{
Affine, AnchorKind, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node,
NodeId, NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps,
NodeId, NodeKind, Obj, OpacityMask, PageBox, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps,
};
use vectorcraft_doc::links::missing_placeholder;
use vectorcraft_doc::placed_document::key_for;
impl<'a> Reader<'a> {
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
@@ -754,18 +755,59 @@ impl<'a> Reader<'a> {
let bytes = Arc::new(png);
let blob = ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) };
let key = linked_key(&blob, &path);
let fresh = !self.doc.images.contains_key(&key);
self.doc.images.entry(key.clone()).or_insert(blob);
self.warn(LINKED_PREVIEWS);
let id = NodeId(self.doc.peek_next_id());
if let Some(shift) = elsewhere
&& self.placed_elsewhere.len() < MAX_PLACED_ELSEWHERE
{
self.placed_elsewhere.push((NodeId(self.doc.peek_next_id()), shift));
self.placed_elsewhere.push((id, shift));
}
// One waits at each depth: a placed file in another's opacity mask doesn't take the place of the outer one.
// An outer one waiting shares its blob with this one now: it no longer has it alone.
let depth = self.frames.len();
self.placed_tails.retain(|t| t.depth < depth);
for t in self.placed_tails.iter_mut().filter(|t| t.key == key) {
t.fresh = false;
}
self.placed_tails.push(PlacedTail { id, key: key.clone(), size: (b.width(), b.height()), fresh, depth });
let mut link = LinkInfo::new(path);
link.relative = folder.and_then(|f| relative_path(&f, link.name()));
self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(link), placement: Default::default(), pixels: None }, hidden)
}
/// The dictionary `o` after the placed file just read: with a page or a box in it, that file is
/// a page of a PDF or `.ai` file. Its image keeps the preview under a key of its own for that
/// page and box, so that it doesn't share one with an image of another page of the file.
pub(super) fn placed_page(&mut self, o: &Obj) {
// The dictionaries of art read inside the placed file's own (its opacity mask's) are not its.
let depth = self.frames.len();
self.placed_tails.retain(|t| t.depth <= depth);
if self.placed_tails.last().is_none_or(|t| t.depth != depth) {
return;
}
let Some(tail) = self.placed_tails.pop() else { return };
let (page, crop) = (o.get(PLACED_PAGE).map(|_| o.nums(PLACED_PAGE)), o.get(PLACED_CROP).map(|_| o.nums(PLACED_CROP)));
// A placed document's box runs from (0, 0) to a positive size: a box written backward stays a linked image.
if (page.is_none() && crop.is_none()) || !(tail.size.0 > 0.0 && tail.size.1 > 0.0) {
return;
}
// An entry that is there but not a number leaves a linked image, as an unknown number does.
let page = page.map_or(Some(1), |v| v.first().copied().and_then(placed_page_number));
let crop = crop.map_or(Some((false, PageBox::Crop)), |v| v.first().copied().and_then(placed_crop));
let (Some(page), Some((bounding, page_box))) = (page, crop) else { return };
let Some(NodeKind::Image(im)) = self.last_object().filter(|n| n.id == tail.id).map(|n| &mut n.kind) else { return };
let old = im.key.clone();
let key = key_for(&old, page, bounding, page_box);
im.key.clone_from(&key);
let blob = if tail.fresh { self.doc.images.remove(&old) } else { self.doc.images.get(&old).cloned() };
if let Some(blob) = blob {
self.doc.images.entry(key).or_insert(blob);
}
self.placed_pages.push(PlacedPage { id: tail.id, page, bounding, page_box, size: tail.size });
}
pub(super) fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 {
self.warnings.push(w.to_string());
@@ -867,6 +909,25 @@ pub(super) fn preview_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
Some((png, width, height))
}
/// The page a placed file's dictionary names: a whole number from 1.
fn placed_page_number(v: f64) -> Option<u32> {
(v.fract() == 0.0 && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v as u32)
}
/// The box a placed file's dictionary names: whether the art's bounds are shown, and the box of
/// the page otherwise.
fn placed_crop(v: f64) -> Option<(bool, PageBox)> {
match v {
0.0 => Some((false, PageBox::Art)),
1.0 => Some((false, PageBox::Crop)),
2.0 => Some((false, PageBox::Trim)),
3.0 => Some((false, PageBox::Bleed)),
4.0 => Some((false, PageBox::Media)),
5.0 => Some((true, PageBox::Crop)),
_ => None,
}
}
/// A placed file's path relative to the document's folder (`/` separators): its relative `folder`
/// (`src/`, `..\art`) followed by its file `name`; the name alone when the folder is empty (the
/// document's own folder). `None` when the folder is absolute or holds control characters, or
@@ -612,6 +612,7 @@ impl<'a> Reader<'a> {
head.registration = Some(*r);
}
self.constraint_art(o);
self.placed_page(o);
// A blend's key object or spine.
if let Some(kind) = o.text("ShapeBlendPathType")
&& let Some(id) = self.last_object().map(|n| n.id)
@@ -1183,6 +1184,7 @@ impl<'a> Reader<'a> {
restyled: self.restyled,
placeholders: self.placeholders,
placed_elsewhere: self.placed_elsewhere,
placed_pages: self.placed_pages,
envelopes_drawn: self.envelopes_drawn,
warped_type: super::super::envelope::WarpedType {
looks: self.warped_type,
+40 -1
View File
@@ -1,4 +1,6 @@
use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Named, Node, Obj, Paint, Rect, V};
use super::{
Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Named, Node, NodeId, Obj, PageBox, Paint, Rect, V,
};
/// Deepest nesting of layers, groups and dictionaries.
pub(super) const MAX_DEPTH: usize = 256;
@@ -310,6 +312,43 @@ pub(super) struct Place {
/// The longest relative folder of a placed file read (bytes).
pub(super) const MAX_PLACED_FOLDER: usize = 4096;
/// The dictionary entry after a placed PDF or `.ai` file that holds the page shown (1-based).
pub(super) const PLACED_PAGE: &str = "AI11 PlacedPDFPageIndex";
/// The dictionary entry after a placed PDF or `.ai` file that holds the box it is cropped to.
pub(super) const PLACED_CROP: &str = "AI11 PlacedPDFCropTo";
/// The placed file just read, until the dictionary after it: it may say the file is a page of a
/// PDF or `.ai` file.
#[derive(Debug)]
pub(super) struct PlacedTail {
/// Its linked image.
pub(super) id: NodeId,
/// Its image's key in the document's images.
pub(super) key: String,
/// The size of its box.
pub(super) size: (f64, f64),
/// Its image's blob was stored for it, not shared with an earlier image.
pub(super) fresh: bool,
/// The frames open when it was read: its dictionary closes at the same depth.
pub(super) depth: usize,
}
/// A placed file the dictionary after it says is a page of a PDF or `.ai` file. Its linked image
/// becomes a placed document once the layers have been compared with the page.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlacedPage {
/// The linked image.
pub id: NodeId,
/// The page shown (1-based).
pub page: u32,
/// The art's bounds are shown, not `page_box`.
pub bounding: bool,
/// The box of the page shown.
pub page_box: PageBox,
/// The size of the placed file's box.
pub size: (f64, f64),
}
/// What the open result says of placed files: they show the preview the file keeps of them.
pub(super) const LINKED_PREVIEWS: &str =
"linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel";
+205
View File
@@ -3288,6 +3288,211 @@ fn placed_files_of_other_paths_keep_images_of_their_own() {
assert_eq!(r.doc.images.len(), 2);
}
/// A placed file followed by the dictionary `numbers` (`value /Int (key)` lines, on `%_` lines).
fn placed_with(numbers: &[(&str, &str)]) -> Structure {
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let entries: String = numbers.iter().map(|(key, v)| format!("%_{v} /Int ({key}) ,\r")).collect();
let dictionary = format!("%_/ArtDictionary :\r{entries}%_;\r%_\r");
read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), &dictionary)).unwrap()
}
#[test]
fn a_placed_files_dictionary_names_the_page_and_box_it_shows() {
use vectorcraft_doc::PageBox;
const PAGE: &str = "AI11 PlacedPDFPageIndex";
const CROP: &str = "AI11 PlacedPDFCropTo";
for (crop, bounding, page_box) in [
("0", false, PageBox::Art),
("1", false, PageBox::Crop),
("2", false, PageBox::Trim),
("3", false, PageBox::Bleed),
("4", false, PageBox::Media),
("5", true, PageBox::Crop),
] {
let r = placed_with(&[(PAGE, "3"), (CROP, crop)]);
let a = art(&r.doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!(r.placed_pages.len(), 1, "{crop}");
let p = r.placed_pages[0];
assert_eq!((p.id, p.page, p.bounding, p.page_box, p.size), (a[0].id, 3, bounding, page_box, (4.0, 2.0)), "{crop}");
assert_eq!(r.doc.images.keys().collect::<Vec<_>>(), [&im.key], "{crop}: the preview is stored once, under the image's key");
assert!(r.doc.images[&im.key].is_proxy(), "{crop}");
}
// One entry alone: the first page, or the page's crop box.
let r = placed_with(&[(CROP, "4")]);
assert_eq!((r.placed_pages[0].page, r.placed_pages[0].page_box), (1, PageBox::Media));
let r = placed_with(&[(PAGE, "2")]);
assert_eq!((r.placed_pages[0].bounding, r.placed_pages[0].page_box), (false, PageBox::Crop));
}
#[test]
fn a_placed_files_dictionary_it_cant_read_leaves_a_linked_image() {
const PAGE: &str = "AI11 PlacedPDFPageIndex";
const CROP: &str = "AI11 PlacedPDFCropTo";
let plain = placed_with(&[]);
let plain_key = {
let a = art(&plain.doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
im.key.clone()
};
assert!(plain.placed_pages.is_empty());
for entries in [
vec![(PAGE, "0"), (CROP, "4")],
vec![(PAGE, "-1"), (CROP, "4")],
vec![(PAGE, "1.5"), (CROP, "4")],
vec![(PAGE, "1e30"), (CROP, "4")],
vec![(PAGE, "2"), (CROP, "6")],
vec![(PAGE, "2"), (CROP, "-1")],
vec![(PAGE, "2"), (CROP, "0.5")],
vec![(PAGE, "(2)"), (CROP, "4")],
vec![(PAGE, "2"), (CROP, "(4)")],
vec![(PAGE, "(2)")],
] {
let r = placed_with(&entries);
assert!(r.placed_pages.is_empty(), "{entries:?}");
let a = art(&r.doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{entries:?}: {:?}", a[0].kind) };
assert_eq!((im.key.as_str(), im.link.as_ref().and_then(|l| l.page)), (plain_key.as_str(), None), "{entries:?}");
}
}
/// The dictionaries of the art in a placed file's opacity mask, read inside its own dictionary,
/// don't speak for the placed file.
#[test]
fn a_placed_files_mask_art_leaves_its_page_to_the_placed_files_dictionary() {
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let dictionary = "%_/ArtDictionary :\r%_/Mask :\r%_X=\r%_0 g\r%_0 0 m\r%_5 0 l\r%_5 -5 l\r%_f\r\
%_/ArtDictionary :\r%_/XMLUID : (Inner) ; (AI10_ArtUID) ,\r%_;\r%_\r%_X+\r%_ (Art) ,\r%_; (AI9_Mask) ,\r\
%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r";
let r = read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), dictionary)).unwrap();
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:?}");
assert!(a[0].extras().mask.is_some(), "the mask is read");
assert_eq!(r.placed_pages.len(), 1);
assert_eq!((r.placed_pages[0].id, r.placed_pages[0].page_box), (a[0].id, vectorcraft_doc::PageBox::Media));
}
/// A placed file in a placed file's opacity mask, read before the outer file's dictionary closes,
/// leaves the outer file's page to that dictionary.
#[test]
fn a_placed_file_in_a_placed_files_mask_leaves_the_outer_files_page() {
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let mut inner =
b"%_%AI5_BeginPlace\r%_%%FileType: 1\r%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\r%_(/art/mask.pdf)`\r%_%AI26_BeginPlacedObjectPreview\r%_"
.to_vec();
inner.extend_from_slice(&preview);
inner.extend_from_slice(
b"\r%_%AI26_EndPlacedObjectPreview\r%_~\r%_%AI5_EndPlace\r%_/ArtDictionary :\r%_5 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r",
);
let mut dictionary = b"%_/ArtDictionary :\r%_/Mask :\r%_X=\r".to_vec();
dictionary.extend_from_slice(&inner);
dictionary
.extend_from_slice(b"%_X+\r%_ (Art) ,\r%_; (AI9_Mask) ,\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r");
let data = placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "@@DICT@@");
let at = data.windows(8).position(|x| x == b"@@DICT@@").unwrap();
let data = [&data[..at], &dictionary[..], &data[at + 8..]].concat();
let r = read(&data).unwrap();
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:?}");
assert!(a[0].extras().mask.is_some(), "the mask is read");
let outer: Vec<_> = r.placed_pages.iter().filter(|p| p.id == a[0].id).collect();
assert_eq!(outer.len(), 1, "{:?}", r.placed_pages);
assert_eq!((outer[0].page, outer[0].page_box), (2, vectorcraft_doc::PageBox::Media));
assert!(r.placed_pages.iter().any(|p| p.id != a[0].id && p.page == 5), "the inner one names its own page: {:?}", r.placed_pages);
}
/// A placed file in a placed file's opacity mask that shows the same file with no page of its own
/// keeps the preview they share when the outer one's dictionary names a page.
#[test]
fn a_placed_file_in_a_placed_files_mask_keeps_the_preview_they_share() {
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let mut dictionary =
b"%_/ArtDictionary :\r%_/Mask :\r%_X=\r%_%AI5_BeginPlace\r%_%%FileType: 1\r%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\r%_(/art/flyer.pdf)`\r\
%_%AI26_BeginPlacedObjectPreview\r%_"
.to_vec();
dictionary.extend_from_slice(&preview);
dictionary.extend_from_slice(
b"\r%_%AI26_EndPlacedObjectPreview\r%_~\r%_%AI5_EndPlace\r%_X+\r%_ (Art) ,\r%_; (AI9_Mask) ,\r\
%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r",
);
let data = placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "@@DICT@@");
let at = data.windows(8).position(|x| x == b"@@DICT@@").unwrap();
let data = [&data[..at], &dictionary[..], &data[at + 8..]].concat();
let r = read(&data).unwrap();
let a = art(&r.doc);
assert_eq!(r.placed_pages.len(), 1, "{:?}", r.placed_pages);
let mask = a[0].extras().mask.clone().expect("the mask is read");
let mut keys = vec![];
mask.art.walk(&mut |n| {
if let NodeKind::Image(im) = &n.kind {
keys.push(im.key.clone());
}
});
let [inner] = &keys[..] else { panic!("{keys:?}") };
assert!(r.doc.images.contains_key(inner), "the mask's image keeps its preview");
let NodeKind::Image(outer) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(r.doc.images.contains_key(&outer.key) && outer.key != *inner);
}
#[test]
fn a_placed_file_with_its_box_written_backward_stays_a_linked_image() {
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let dictionary = "%_/ArtDictionary :\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r";
let data = placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), dictionary);
let at = data.windows(11).position(|x| x == b"10 20 14 22").unwrap();
let data = [&data[..at], b"14 22 10 20", &data[at + 11..]].concat();
let r = read(&data).unwrap();
assert!(r.placed_pages.is_empty());
let a = art(&r.doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!(im.link.as_ref().and_then(|l| l.page), None);
}
#[test]
fn only_the_dictionary_right_after_a_placed_file_names_its_page() {
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let dictionary = "%_/ArtDictionary :\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_4 /Int (AI11 PlacedPDFCropTo) ,\r%_;\r%_\r";
// After another object: that object's dictionary. Art with such a dictionary stays art.
let after = format!("0 g\r0 0 m\r5 0 l\r5 5 l\rf\r{dictionary}");
let r = read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), &after)).unwrap();
assert!(r.placed_pages.is_empty());
assert_eq!(art(&r.doc).len(), 2);
// A second dictionary after the first: the first one spoke for the placed file.
let after = format!("%_/ArtDictionary :\r%_(Photo) /String (AIArtName) ,\r%_;\r%_\r{dictionary}");
let r = read(&placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), &after)).unwrap();
assert!(r.placed_pages.is_empty());
// Without a placed file before it, nothing happens.
let r = read(stream(&layer(&format!("0 g\r0 0 m\r5 0 l\r5 5 l\rf\r{dictionary}"))).as_bytes()).ok();
assert!(r.is_none_or(|r| r.placed_pages.is_empty()));
}
/// Two pages of a file with the same preview don't share a key: reading one page's file in would
/// change the other's image.
#[test]
fn placed_pages_of_one_file_keep_images_of_their_own() {
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let one =
placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "%_/ArtDictionary :\r%_1 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r");
let two =
placed(true, "[5 0 0 -5 0 200]", "/art/flyer.pdf", Some(&preview), "%_/ArtDictionary :\r%_2 /Int (AI11 PlacedPDFPageIndex) ,\r%_;\r%_\r");
let find = |d: &[u8], w: &[u8]| d.windows(w.len()).position(|x| x == w).unwrap();
let body = two[find(&two, b"%AI5_BeginPlace")..find(&two, b"LB\n%AI5_EndLayer")].to_vec();
let mut both = one.clone();
let at = find(&both, b"LB\n%AI5_EndLayer");
both.splice(at..at, body);
let r = read(&both).unwrap();
assert_eq!(r.placed_pages.len(), 2);
let keys: Vec<String> = art(&r.doc)
.iter()
.map(|n| match &n.kind {
NodeKind::Image(im) => im.key.clone(),
k => panic!("{k:?}"),
})
.collect();
assert_ne!(keys[0], keys[1]);
assert!(keys.iter().all(|k| r.doc.images.contains_key(k)) && r.doc.images.len() == 2, "{keys:?}");
}
#[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.
+53
View File
@@ -9,6 +9,7 @@ use super::slots::{
use super::{
Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, VariableKind, Vec2, ai,
};
use vectorcraft_doc::PlacedDocument;
/// Which box of the editing copy is the page.
#[derive(Clone, Copy)]
@@ -203,6 +204,7 @@ pub(super) fn build_from(
restyled,
placeholders,
placed_elsewhere,
placed_pages,
envelopes_drawn,
styles_read,
canvas_scale,
@@ -491,6 +493,7 @@ pub(super) fn build_from(
}
doc.limit_depth_noting(&mut notes);
bind_variables(&mut doc, &bindings, bindings_skipped, &xml_ids, &filled, &mut notes);
placed_documents(&mut doc, &placed_pages);
if small.is_some() {
scale_document(&mut doc, unit, styles_read);
relayout_wrapped(&mut doc);
@@ -498,6 +501,56 @@ pub(super) fn build_from(
Ok((doc, notes))
}
/// The linked images of `pages` that are still in `doc`, opacity-mask art included, as placed
/// documents of their page and box, over the same place, each showing the same preview until its
/// file is read. An image bound to an image variable stays a linked image, the object an image
/// variable binds.
fn placed_documents(doc: &mut Document, pages: &[ai::PlacedPage]) {
if pages.is_empty() {
return;
}
let vars = &doc.variables;
let image_bound = |id: &NodeId| {
vars.bindings.get(id).is_some_and(|names| names.iter().any(|v| vars.variable(v).is_some_and(|v| v.kind == VariableKind::Image)))
};
let pages: std::collections::HashMap<NodeId, ai::PlacedPage> = pages.iter().filter(|p| !image_bound(&p.id)).map(|p| (p.id, *p)).collect();
for layer in &mut doc.layers {
to_placed_documents(Arc::make_mut(layer), &pages);
}
}
/// [`placed_documents`] in `n` and everything in it.
fn to_placed_documents(n: &mut Node, pages: &std::collections::HashMap<NodeId, ai::PlacedPage>) {
// A box too small to take the image's pixels to (its transform not finite) keeps the linked image.
if let Some(page) = pages.get(&n.id)
&& let NodeKind::Image(im) = &n.kind
&& let Some(link) = &im.link
&& let (width, height) = page.size
&& let xf = im.xf * Affine::scale_non_uniform(f64::from(im.width) / width, f64::from(im.height) / height)
&& xf.as_coeffs().iter().all(|v| v.is_finite())
&& xf.determinant().abs() > 1e-12
{
let link = vectorcraft_doc::LinkInfo { page: Some(page.page), ..link.clone() };
let placed = PlacedDocument {
link,
key: im.key.clone(),
bounding: page.bounding,
page_box: page.page_box,
width,
height,
xf,
placement: im.placement,
};
n.kind = NodeKind::PlacedDocument(Box::new(placed));
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
to_placed_documents(Arc::make_mut(&mut m.art), pages);
}
for c in n.children_mut().into_iter().flatten() {
to_placed_documents(Arc::make_mut(c), pages);
}
}
/// Bind the objects of `doc` to the variables of its library that `bindings` binds them to, each
/// object named by its XML id (`ids`: the object read with that id, or what `filled` made of a text
/// object's slot: for a visibility variable every object that took the slot's place, for a text
+97
View File
@@ -5329,6 +5329,103 @@ fn a_placed_file_at_its_boxs_corner_matches_the_page() {
assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}");
}
/// A placed file of [`a_placed_file_at_its_boxs_corner_matches_the_page`] whose dictionary names
/// page 2 and its media box: `%_` lines when `hidden` (art inside another object's dictionary).
fn placed_page_two(hidden: bool) -> String {
let lines = [
"%AI5_BeginPlace".to_string(),
"%%FileType: 1".into(),
"[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0".into(),
"(/files/pages.pdf)`".into(),
"%AI26_BeginPlacedObjectPreview".into(),
format!("2 2 8 32 17 {}", "\x7f\0\0\0".repeat(4)),
"%AI26_EndPlacedObjectPreview".into(),
"~".into(),
"%AI5_EndPlace".into(),
];
let hide = if hidden { "%_" } else { "" };
let place: String = lines.iter().map(|l| format!("{hide}{l}\n")).collect();
format!(
"{place}%_/ArtDictionary :\n%_/XMLUID : (pages) ; (AI10_ArtUID) ,\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n"
)
}
/// The placed document of `n`, with its bounds.
fn placed_document(n: &Node) -> (&vectorcraft_doc::PlacedDocument, vectorcraft_geom::Rect) {
let NodeKind::PlacedDocument(p) = &n.kind else { panic!("{:?}", n.kind) };
(p, n.geometric_bounds().unwrap())
}
/// A placed PDF page comes in as a placed document of its page and box, where its linked image
/// would have gone: at its box's corner when that is where the layers draw as the page does.
#[test]
fn a_placed_pdf_page_is_a_placed_document_where_its_image_would_be() {
let data = native(&layer("Jig", true, &placed_page_two(false))).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 grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5)));
let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() });
let l = page.layers[0].id;
page.insert(Some(l), 0, n).unwrap();
let (d, w, read) = crate::layered_ai(&private(&data), page, vec![], false, false, 1.0);
assert!(read, "{w:?}");
let (p, b) = placed_document(&d.layers[0].children().unwrap()[0]);
assert_eq!((p.link.path.as_str(), p.link.page, p.page_box, p.bounding), ("/files/pages.pdf", Some(2), vectorcraft_doc::PageBox::Media, false));
assert_eq!((p.width, p.height), (40.0, 40.0));
assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}");
assert!(d.images[&p.key].is_proxy(), "the preview until the file is read");
}
/// A placed PDF page in an object's opacity mask is a placed document there too.
#[test]
fn a_placed_pdf_page_in_an_opacity_mask_is_a_placed_document() {
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{}%_X+\n%_ (Art) ,\n%_; (AI9_Mask) ,\n%_;\n%_\n", placed_page_two(true));
let data = native(&layer("Masked", true, &format!("{}\n{mask}", square(10, 10))));
let (d, notes) = crate::ai_alone(&private(&data)).unwrap();
let square = &d.layers[0].children().unwrap()[0];
let mask = square.extras().mask.clone().unwrap_or_else(|| panic!("{notes:?}"));
let art = if let NodeKind::Group { children, .. } = &mask.art.kind { children[0].clone() } else { mask.art.clone() };
let (p, _) = placed_document(&art);
assert_eq!((p.link.page, p.page_box), (Some(2), vectorcraft_doc::PageBox::Media));
}
/// A placed PDF page whose box is too small for a finite placement stays a linked image.
#[test]
fn a_placed_pdf_page_too_small_to_place_stays_a_linked_image() {
let place = placed_page_two(false).replace("[1 0 0 -1 -7970 8070] 8000 8000 8040 8040", "[1e308 0 0 -1 0 200] 0 0 1e-309 2");
let data = native(&layer("Jig", true, &place));
let (d, notes) = crate::ai_alone(&private(&data)).unwrap();
let mut kinds = vec![];
d.walk(|n| match &n.kind {
NodeKind::Image(im) => kinds.push(("image", im.xf.as_coeffs().iter().all(|v| v.is_finite()))),
NodeKind::PlacedDocument(p) => kinds.push(("placed", p.xf.as_coeffs().iter().all(|v| v.is_finite()))),
_ => {}
});
assert_eq!(kinds, [("image", true)], "{notes:?}");
}
/// A placed PDF page bound to an image variable stays a linked image, the object an image variable
/// binds, so the binding is kept.
#[test]
fn a_placed_pdf_page_bound_to_an_image_variable_stays_a_linked_image() {
use vectorcraft_testkit::ai::{XmlValue, variable_library_data, xml_element, xml_node};
let declared = vec![xml_node(
1,
"variable",
XmlValue::Str(""),
&[("trait", XmlValue::Str("fileref")), ("varName", XmlValue::Unicode("v_file")), ("docRef", XmlValue::Raw("/XMLUIDREF : (pages) ;"))],
&[],
)];
let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &declared)]);
let library = variable_library_data(&xml_element("variableSets", &[], &[set]));
let data = native(&(library + &layer("Jig", true, &placed_page_two(false))));
let (d, notes) = crate::ai_alone(&private(&data)).unwrap();
let n = &d.layers[0].children().unwrap()[0];
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_ref().is_some_and(|l| l.page.is_none())), "{:?} {notes:?}", n.kind);
assert_eq!(d.variables.objects_of("v_file"), [n.id], "{notes:?}");
}
#[test]
fn a_small_placed_file_goes_where_it_draws_closer_to_the_page() {
// A 2 × 2 placed file (a small logo on a large page) whose box corner the matrix takes to
File diff suppressed because one or more lines are too long
+1
View File
@@ -1313,6 +1313,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked.
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, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). 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).
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 placed PDF or `.ai` file in a `.ai`'s editing data comes in as a placed document instead of a linked image: it shows the page and Crop to box its dictionary names, and the preview the file keeps until its file is found, then the page as vectors. A placed file in opacity-mask art keeps showing its preview (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/placed_document.rs`).
In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`).
In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art with several clipping paths (`W`) clips to their intersection: each clipping path after the first clips a clipping group nested inside the one before it, and each keeps its own stroke and transparency. A compound path of clipping paths (`*u` … `*U`) is still one clipping path, and clipping paths that would nest the art past the document's 32 levels stop the editing data from being read (`crates/eps/src/import/ai/tests.rs`).
`.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).