From c730a1c7767aa1e165adbfc7426cc157bbf01a3a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 15:13:48 -0800 Subject: [PATCH 1/4] Import PDF: linked pages refuse a .ai saved without PDF compatibility linkPages refused other files but let a .ai whose page is only a placeholder through, and each such page then showed the file's editing data. A page read from editing data is now refused with the existing error. The file is also hashed once for all its linked pages instead of once per page. --- crates/engine/src/cmd/fileio/pdfimport.rs | 9 +++++- .../engine/src/cmd/fileio/tests_aiimport.rs | 21 ++++++++++++++ crates/engine/src/cmd/place.rs | 2 +- crates/engine/src/cmd/place/document.rs | 29 ++++++++++++------- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index b0f08ca4c5..385db4a4ac 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -253,10 +253,17 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa let gap = ImportOptions::default().artboard_gap; // Every page's object keeps the same one copy of the file. let shared = Arc::new(bytes.to_vec()); + // The file is hashed once for all its pages. + let hash = link.hash.clone().unwrap_or_else(|| vectorcraft_doc::links::hash_bytes(bytes)); let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::::new(), 0); for (i, &index) in picked.iter().enumerate() { let page = index as u32 + 1; - let s = document::shared_source(name, &shared, Some(path), page, bounding, document::page_box(o.crop), C)?; + let s = document::shared_source(name, (&shared, &hash), Some(path), page, bounding, document::page_box(o.crop), C)?; + // A page that is only a placeholder (a `.ai` saved without PDF compatibility) shows the + // file's editing data, not a page of the file. + if s.editing_data { + return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file"))); + } cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk); let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding); if let NodeKind::PlacedDocument(p) = &mut node.kind { diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index c48b437b76..2aaa2b8a68 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1160,6 +1160,27 @@ fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() { assert!(e.contains("placeholder"), "{e}"); } +/// `document.open` with `linkPages` refuses a `.ai` saved without PDF compatibility, whose page is +/// only a placeholder, and opens a PDF-compatible one. +#[test] +fn link_pages_refuses_an_ai_file_saved_without_its_pdf_part() { + let d = std::env::temp_dir().join(format!("vc-ailinkpages-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let file = |name: &str, page: &str| { + let path = d.join(name); + std::fs::write(&path, ai::ai(&compressed(&ai::sample_data()), page)).unwrap(); + path.to_string_lossy().to_string() + }; + let (plain, compatible) = (file("plain.ai", PLACEHOLDER), file("compatible.ai", "0 g 10 10 40 40 re f")); + let mut s = Session::new(); + let e = s.execute("document.open", &json!({"path": plain, "linkPages": true})).unwrap_err().to_string(); + assert!(e.contains("isn't a PDF or a PDF-compatible .ai file"), "{e}"); + assert!(s.documents().is_empty(), "nothing opened"); + s.execute("document.open", &json!({"path": compatible, "linkPages": true})).unwrap(); + assert!(s.doc().unwrap().doc.has_placed()); + let _ = std::fs::remove_dir_all(&d); +} + /// A page of text alone, as text-only art has in its PDF part: no path is painted. const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index dc95f925b6..ca9ce2cbd0 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -348,7 +348,7 @@ pub(crate) fn pdf_art( let clip = (!bounding).then_some(board); let frame = clip.or_else(|| shown_bounds(&symbols, &nodes)); let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; - Ok(document::DocumentArt { doc, nodes, clip, frame, warnings }) + Ok(document::DocumentArt { doc, nodes, clip, frame, warnings, editing_data: alone }) } /// The bounds of what `nodes` draw, as an export measures art diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 5809990e0f..0e9b2c5490 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -100,6 +100,9 @@ pub(crate) struct DocumentArt { /// What is shown: the artboard, or the art's bounds. pub frame: Rect, pub warnings: Vec, + /// Whether a PDF or `.ai` page was read from the file's editing data (its page only a + /// placeholder, or its PDF part unreadable) instead of from the page. + pub editing_data: bool, } /// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when @@ -145,7 +148,7 @@ pub(crate) fn document_art( let nodes = super::on_artboard(&symbols, &layers, board); let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) }; let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?; - Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings }) + Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings, editing_data: false }) } /// The most placed documents read inside one another while their links are resolved. @@ -223,6 +226,8 @@ pub(crate) struct Source { pub warnings: Vec, /// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK). pub color_mode: ColorMode, + /// [`DocumentArt::editing_data`]. + pub editing_data: bool, } /// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing @@ -233,32 +238,36 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo } /// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own: -/// several pages of one file read this way share one copy of it. +/// several pages of one file read this way share one copy of it, with its hash ([`hash_bytes`]) +/// worked out once for them all: `file` is the bytes and that hash. pub(crate) fn shared_source( name: &str, - bytes: &Arc>, + file: (&Arc>, &str), path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str, ) -> Result { - read_source(name, bytes, Some(bytes), path, (page, bounding, page_box), cmd) + read_source(name, file.0, Some(file), path, (page, bounding, page_box), cmd) } -/// [`source`], the blob keeping `shared` when given (else a copy of `bytes`); `shown` is the page -/// (or artboard), whether the art's bounds are shown, and the page's box. +/// [`source`], the blob keeping `shared`'s bytes when given (else a copy of `bytes`), and its hash +/// used for the key (else `bytes` hashed); `shown` is the page (or artboard), whether the art's +/// bounds are shown, and the page's box. fn read_source( name: &str, bytes: &[u8], - shared: Option<&Arc>>, + shared: Option<(&Arc>, &str)>, path: Option<&str>, shown: (u32, bool, PageBox), cmd: &str, ) -> Result { let (page, bounding, page_box) = shown; + let hash = shared.map_or_else(|| hash_bytes(bytes), |(_, h)| h.to_string()); + let shared = shared.map(|(b, _)| b); // The same document placed the same way is read once (placing it again is free). - let key = key_for(&hash_bytes(bytes), page, bounding, page_box); + let key = key_for(&hash, page, bounding, page_box); // Resolving its links depends on where the file is and on what the caller may read, so a hit // needs the same path, and nothing is shared with or from a confined (automation) caller. let confined = crate::file_access::current().is_some(); @@ -284,10 +293,10 @@ fn read_source( }; let art = group(a.nodes, a.clip); blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); - let color_mode = a.doc.color_mode; + let (color_mode, editing_data) = (a.doc.color_mode, a.editing_data); // Drawing it needn't read it again. vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); - let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode }; + let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode, editing_data }; if !confined { let mut m = lock_memo(); if m.len() >= MEMO_SIZE { From cc7d081198aa75a1795d2ed46382e40a9aabdf3b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 15:48:01 -0800 Subject: [PATCH 2/4] Import PDF: linked pages read the file once and keep blank pages linkPages read and checked the whole PDF again for every page it linked. The pages are now read from the file opened once. With cropTo: bounding, a blank page stopped the open with "has no art to place"; it now gets its crop box, as opening the page as art does. --- crates/engine/src/cmd/fileio.rs | 1 + crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/pdfimport.rs | 16 +++++-- crates/engine/src/cmd/place.rs | 16 ++++--- crates/engine/src/cmd/place/document.rs | 52 ++++++++++++++++------ crates/engine/src/tests/placed_document.rs | 20 +++++++++ docs/mcp.md | 2 +- 7 files changed, 85 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6995075d2f..bd6d1ddf69 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -91,6 +91,7 @@ pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; +pub(crate) use pdfimport::opened_page_document; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index a46f46492a..a3427bce3f 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 385db4a4ac..15a07e5aee 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -205,7 +205,7 @@ pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Docu } /// [`page_document`] of `opened`, the PDF `bytes` already read. -fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { +pub(crate) fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { vectorcraft_pdf::import_opened(bytes, opened, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err) } @@ -217,7 +217,7 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions { /// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or /// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to /// that page of the file as File › Place with Link places one ([`document::source`]). Each page -/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`), in a row from the left +/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`, a blank page's crop box), in a row from the left /// with the gap a PDF's artboards open with, and a layer named after the page. The document opens /// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a /// path, for any other file, with a password (a linked page is read again without one), with @@ -239,7 +239,9 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa if bytes.len() > document::MAX_FILE { return Err(bad(C, format!("linkPages: `{file}` is over {} MB", document::MAX_FILE >> 20))); } - let count = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?.page_count(); + // The file is read once: every page is read from it. + let opened = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?; + let count = opened.page_count(); let picked = match &o.pages { None => (0..count).collect(), Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?, @@ -258,7 +260,13 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::::new(), 0); for (i, &index) in picked.iter().enumerate() { let page = index as u32 + 1; - let s = document::shared_source(name, (&shared, &hash), Some(path), page, bounding, document::page_box(o.crop), C)?; + let file_once = document::SharedFile { bytes: &shared, hash: &hash, opened: &opened }; + let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, document::page_box(o.crop), C); + // A blank page has no art's bounds: it keeps its crop box, as opening it as art does. + let (s, bounding) = match read(bounding) { + Err(e) if bounding && e.to_string().contains("has no art to place") => (read(false)?, false), + s => (s?, bounding), + }; // A page that is only a placeholder (a `.ai` saved without PDF compatibility) shows the // file's editing data, not a page of the file. if s.editing_data { diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index ca9ce2cbd0..b2cb143eb2 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -254,7 +254,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result, link_pdf: bool) -> Result), path: Option<&str>, page: usize, opts: fileio::LoadOptions, @@ -318,11 +320,15 @@ pub(crate) fn pdf_art( let name = fileio::file_name(src); let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?; let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts }; - let mut read = fileio::page_document(bytes, index, &o); + let page_document = |i: usize| match opened { + Some(opened) => fileio::opened_page_document(bytes, opened, i, &o), + None => fileio::page_document(bytes, i, &o), + }; + let mut read = page_document(index); // Such a file can have more artboards than placeholder pages. if page > 1 && read.is_err() - && let Err(e) = fileio::page_document(bytes, 0, &o) + && let Err(e) = page_document(0) && fileio::is_placeholder(&e) { read = Err(e); diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 0e9b2c5490..0d9a1470a8 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -117,6 +117,21 @@ pub(crate) fn document_art( bounding: bool, page_box: PageBox, cmd: &str, +) -> Result { + opened_document_art(name, (bytes, None), path, page, bounding, page_box, cmd) +} + +/// [`document_art`] of `bytes` and, for a PDF already read, `opened`: the PDF `bytes` as +/// [`vectorcraft_pdf::open_pdf`] read it without a password, so its page is read without reading +/// the file again. +fn opened_document_art( + name: &str, + (bytes, opened): (&[u8], Option<&vectorcraft_pdf::Opened>), + path: Option<&str>, + page: u32, + bounding: bool, + page_box: PageBox, + cmd: &str, ) -> Result { let file = fileio::file_name(name); let pdf = is_pdf(name, bytes); @@ -128,7 +143,7 @@ pub(crate) fn document_art( } if pdf { let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() }; - return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd); + return super::pdf_art(name, (bytes, opened), path, page as usize, o, bounding, cmd); } let mut l = fileio::load(name, bytes)?; // Its linked images show their files; its placed documents stay as they are, unless their @@ -237,35 +252,46 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo read_source(name, bytes, None, path, (page, bounding, page_box), cmd) } -/// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own: -/// several pages of one file read this way share one copy of it, with its hash ([`hash_bytes`]) -/// worked out once for them all: `file` is the bytes and that hash. +/// A PDF (or PDF-compatible `.ai`) whose pages are read for placing linked, read once for them all. +#[derive(Clone, Copy)] +pub(crate) struct SharedFile<'a> { + /// The file's bytes, which every page's blob keeps without a copy of its own. + pub bytes: &'a Arc>, + /// The bytes' hash ([`hash_bytes`]). + pub hash: &'a str, + /// The bytes as [`vectorcraft_pdf::open_pdf`] read them, without a password. + pub opened: &'a vectorcraft_pdf::Opened, +} + +/// [`source`] of page `page` of `file`: several pages of one file read this way share one copy of +/// it, and the file is hashed and read once for them all. pub(crate) fn shared_source( name: &str, - file: (&Arc>, &str), + file: SharedFile<'_>, path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str, ) -> Result { - read_source(name, file.0, Some(file), path, (page, bounding, page_box), cmd) + read_source(name, file.bytes, Some(file), path, (page, bounding, page_box), cmd) } -/// [`source`], the blob keeping `shared`'s bytes when given (else a copy of `bytes`), and its hash -/// used for the key (else `bytes` hashed); `shown` is the page (or artboard), whether the art's -/// bounds are shown, and the page's box. +/// [`source`], the blob keeping `shared`'s bytes when given (else a copy of `bytes`), its hash +/// used for the key (else `bytes` hashed) and its pages read from what was read of it; `shown` is +/// the page (or artboard), whether the art's bounds are shown, and the page's box. fn read_source( name: &str, bytes: &[u8], - shared: Option<(&Arc>, &str)>, + shared: Option>, path: Option<&str>, shown: (u32, bool, PageBox), cmd: &str, ) -> Result { let (page, bounding, page_box) = shown; - let hash = shared.map_or_else(|| hash_bytes(bytes), |(_, h)| h.to_string()); - let shared = shared.map(|(b, _)| b); + let hash = shared.map_or_else(|| hash_bytes(bytes), |f| f.hash.to_string()); + let opened = shared.map(|f| f.opened); + let shared = shared.map(|f| f.bytes); // The same document placed the same way is read once (placing it again is free). let key = key_for(&hash, page, bounding, page_box); // Resolving its links depends on where the file is and on what the caller may read, so a hit @@ -278,7 +304,7 @@ fn read_source( } return Ok(s); } - let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?; + let a = opened_document_art(name, (bytes, opened), path, page, bounding, page_box, cmd)?; let bytes = shared.cloned().unwrap_or_else(|| Arc::new(bytes.to_vec())); let mut blob = ImageBlob { mime: mime(name, &bytes).into(), bytes, proxy: None }; let shown = PlacedDocument { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 65808d2360..662e301caa 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -666,6 +666,26 @@ fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() { assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins"); } +#[test] +fn linked_pages_read_the_file_once_and_a_blank_page_keeps_its_crop_box() { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let dir = Folder::new("open-pdf-link-pages-blank"); + let src = dir.file("blank.pdf"); + let art = || PdfPage::new(100.0, 50.0, "1 0 0 rg 10 10 20 20 re f"); + write(&src, &pdf(&[art(), PdfPage::new(80.0, 40.0, ""), art()], None)); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap(); + let d = &s.doc().unwrap().doc; + let boards: Vec = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards.len(), 3, "{boards:?}"); + assert_eq!((boards[0].width(), boards[0].height()), (20.0, 20.0), "the art's bounds"); + assert_eq!((boards[1].width(), boards[1].height()), (80.0, 40.0), "the blank page's crop box"); + // The pages are read from the one copy every page's blob keeps: a page that read the file + // again would count here. + let blob = d.images.values().next().map(|b| b.bytes.clone()).unwrap(); + assert_eq!(vectorcraft_pdf::reads_of(&blob), 0, "the pages are read from the file read once"); +} + #[test] fn link_pages_is_refused_without_a_path_for_other_files_and_with_a_password_or_outlines() { let dir = Folder::new("open-pdf-link-pages-refused"); diff --git a/docs/mcp.md b/docs/mcp.md index 99b0bfef38..265211778f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1279,7 +1279,7 @@ opens at its size in points, the file's numbers times the `UserUnit` (one above such a page opens from that data at the page's size. `linkPages: true` (File › Open's Import PDF dialog: Import PDF pages as links) opens each page `pages` picks as a placed document linked to that page of the file, as `file.place` with `link` places one, instead of as editable art. -Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds), in a row from the left 36 pt apart, and +Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds; a blank page gets its crop box), in a row from the left 36 pt apart, and a layer named after the page (`Page 2`). The document opens in CMYK when most of those pages are painted mostly in CMYK, unless `colorMode` names a mode. All the pages' objects share one copy of the file. The Links panel lists each page, and `links.embed` makes a page editable art. From e6bfe40d30fd2f640afcf977acddd921451a0ed4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 16:39:33 -0800 Subject: [PATCH 3/4] Import PDF: linked .ai pages open on their trim boxes with the bleed With linkPages and cropTo: crop, a PDF-compatible .ai opened on its crop boxes, while opening it as art uses its trim boxes. It now uses the trim boxes too. A page on its trim box shows its bleed box round the artboard, and the pages' bleed (the widest on each side) becomes the document's bleed, as the importer sets it. --- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/pdfimport.rs | 24 +++++++++++++++---- .../engine/src/cmd/fileio/tests_aiimport.rs | 24 +++++++++++++++++++ crates/pdf/src/pages.rs | 11 +++++++++ docs/mcp.md | 4 +++- 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index a3427bce3f..6a147a49cf 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 15a07e5aee..decd7ec35b 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -217,8 +217,11 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions { /// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or /// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to /// that page of the file as File › Place with Link places one ([`document::source`]). Each page -/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`, a blank page's crop box), in a row from the left -/// with the gap a PDF's artboards open with, and a layer named after the page. The document opens +/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`, a blank page's crop box; +/// a PDF-compatible `.ai` asked for its crop box gets its trim box, as it opens), in a row from the +/// left with the gap a PDF's artboards open with, and a layer named after the page. On its trim box, +/// a page shows its bleed box round the artboard, and the document's bleed is the pages' bleed (the +/// widest on each side). The document opens /// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a /// path, for any other file, with a password (a linked page is read again without one), with /// `textAs: outlines` (a linked page shows the file's own type) and for a file over @@ -247,6 +250,8 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?, }; let bounding = o.crop == CropTo::Bounding; + // A PDF-compatible `.ai` asked for its crop box opens on its trim boxes ([`LoadOptions::import_options`]). + let crop = if o.crop == CropTo::Crop && opened.has_illustrator_data() { CropTo::Trim } else { o.crop }; let mut doc = Document::new(1.0, 1.0); let board = doc.artboards.first().cloned().ok_or_else(|| err("a new document has no artboard"))?; doc.artboards.clear(); @@ -261,7 +266,11 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa for (i, &index) in picked.iter().enumerate() { let page = index as u32 + 1; let file_once = document::SharedFile { bytes: &shared, hash: &hash, opened: &opened }; - let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, document::page_box(o.crop), C); + // On its trim box, a page shows its bleed box, the artboard on the trim box inside it, so + // the art in the bleed shows and prints as bleed. + let bleed = if crop == CropTo::Trim { opened.page_bleed(index) } else { None }; + let page_box = if bleed.is_some() { vectorcraft_doc::PageBox::Bleed } else { document::page_box(crop) }; + let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, page_box, C); // A blank page has no art's bounds: it keeps its crop box, as opening it as art does. let (s, bounding) = match read(bounding) { Err(e) if bounding && e.to_string().contains("has no art to place") => (read(false)?, false), @@ -273,9 +282,14 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file"))); } cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk); + // The document's bleed is the widest of the pages' on each side. + let [top, bottom, left, right] = bleed.unwrap_or_default(); + for (side, b) in doc.setup.bleed.iter_mut().zip([top, bottom, left, right]) { + *side = side.max(b.min(vectorcraft_doc::setup::MAX_BLEED)); + } let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding); if let NodeKind::PlacedDocument(p) = &mut node.kind { - p.xf = Affine::translate((x, 0.0)); + p.xf = Affine::translate((x - left, -top)); } node.name = Some(file.clone()); let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8)); @@ -283,7 +297,7 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa children.push(Arc::new(node)); } doc.layers.push(Arc::new(layer)); - let rect = Rect::new(x, 0.0, x + s.frame.width(), s.frame.height()); + let rect = Rect::new(x, 0.0, x + s.frame.width() - left - right, s.frame.height() - top - bottom); doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() }); x = rect.x1 + gap; for w in s.warnings { diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 2aaa2b8a68..92374e63e7 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1181,6 +1181,30 @@ fn link_pages_refuses_an_ai_file_saved_without_its_pdf_part() { let _ = std::fs::remove_dir_all(&d); } +/// `document.open` with `linkPages` opens a PDF-compatible `.ai` asked for its crop box on its trim +/// box, as it opens as art, showing its bleed box round the artboard, which is the document's bleed. +#[test] +fn link_pages_open_an_ai_file_on_its_trim_box_with_its_bleed() { + let d = std::env::temp_dir().join(format!("vc-ailinkpagestrim-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("trimmed.ai"); + let boxes = "/TrimBox [10 10 190 90] /BleedBox [4 2 196 98]"; + std::fs::write(&path, ai::ai_with(&compressed(&ai::sample_data()), "0 g 10 10 40 40 re f", boxes)).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + let board = doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (180.0, 80.0), "the trim box"); + assert_eq!(doc.setup.bleed, [8.0, 8.0, 6.0, 6.0], "top, bottom, left, right"); + // The page shows its bleed box, so the art in the bleed is kept. + let placed = doc.layers[0].children().unwrap()[0].clone(); + let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("{:?}", placed.kind) }; + assert_eq!((p.width, p.height, p.page_box), (192.0, 96.0, vectorcraft_doc::PageBox::Bleed)); + assert_eq!(p.xf.translation(), vectorcraft_geom::Vec2::new(-6.0, -8.0), "the trim box on the artboard"); + let _ = std::fs::remove_dir_all(&d); +} + /// A page of text alone, as text-only art has in its PDF part: no path is painted. const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 013d09f22d..683a14d1c3 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -297,6 +297,17 @@ impl Opened { bleed, } } + + /// How far page `index`'s (0-based) bleed box reaches past its trim box, `[top, bottom, left, + /// right]` in points, as the page shows (rotated); `None` past the last page, and when the bleed + /// box doesn't hold the trim box. + pub fn page_bleed(&self, index: usize) -> Option<[f64; 4]> { + let page = self.pdf.pages().get(index)?; + let init = Affine::new(page.initial_transform(true).as_coeffs()); + let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed))); + let sides = [trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1]; + sides.iter().all(|b| b.is_finite() && *b >= 0.0).then_some(sides) + } } /// A PDF's pages as an import on their trim boxes sizes them ([`Opened::trim_pages`]). diff --git a/docs/mcp.md b/docs/mcp.md index 265211778f..4a01284fd6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1280,7 +1280,9 @@ such a page opens from that data at the page's size. `linkPages: true` (File › Open's Import PDF dialog: Import PDF pages as links) opens each page `pages` picks as a placed document linked to that page of the file, as `file.place` with `link` places one, instead of as editable art. Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds; a blank page gets its crop box), in a row from the left 36 pt apart, and -a layer named after the page (`Page 2`). The document opens in CMYK when most of those pages are painted mostly in +a layer named after the page (`Page 2`). A PDF-compatible `.ai` with `cropTo: "crop"` gets its trim boxes, as it does +when it opens as art. A page on its trim box shows its bleed box round the artboard, and the pages give the document +their bleed. The document opens in CMYK when most of those pages are painted mostly in CMYK, unless `colorMode` names a mode. All the pages' objects share one copy of the file. The Links panel lists each page, and `links.embed` makes a page editable art. `document.open` refuses `linkPages` for a file that is not a PDF or a PDF-compatible `.ai`, for a file given as From 3b788515d109871aaf6691dd7a2616a4c46dee40 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:56:30 -0800 Subject: [PATCH 4/4] Import PDF: linked pages on their trim boxes keep the bleed between artboards Linked pages on their trim boxes were spaced by the 36 pt gap alone, so with a large bleed one page's bleed box reached into the next artboard and a page export could show part of its neighbor. The artboards are now laid out once the document's bleed is known, 36 pt apart plus the bleed on both sides: the document's bleed, or a page's own bleed box where it reaches farther. --- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/pdfimport.rs | 38 ++++++++++++------ .../engine/src/cmd/fileio/tests_aiimport.rs | 40 +++++++++++++++++++ docs/mcp.md | 2 +- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 6a147a49cf..1710f57eb9 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index decd7ec35b..00eef89a11 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -221,7 +221,7 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions { /// a PDF-compatible `.ai` asked for its crop box gets its trim box, as it opens), in a row from the /// left with the gap a PDF's artboards open with, and a layer named after the page. On its trim box, /// a page shows its bleed box round the artboard, and the document's bleed is the pages' bleed (the -/// widest on each side). The document opens +/// widest on each side); the artboards are then the gap apart plus the bleed on both sides. The document opens /// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a /// path, for any other file, with a password (a linked page is read again without one), with /// `textAs: outlines` (a linked page shows the file's own type) and for a file over @@ -262,8 +262,11 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa let shared = Arc::new(bytes.to_vec()); // The file is hashed once for all its pages. let hash = link.hash.clone().unwrap_or_else(|| vectorcraft_doc::links::hash_bytes(bytes)); - let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::::new(), 0); - for (i, &index) in picked.iter().enumerate() { + let (mut warnings, mut cmyk_pages) = (Vec::::new(), 0); + // Each page as read, with its bleed (top, bottom, left, right). The pages are laid out once + // the document's bleed is known. + let mut read_pages = Vec::with_capacity(picked.len()); + for &index in &picked { let page = index as u32 + 1; let file_once = document::SharedFile { bytes: &shared, hash: &hash, opened: &opened }; // On its trim box, a page shows its bleed box, the artboard on the trim box inside it, so @@ -272,7 +275,7 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa let page_box = if bleed.is_some() { vectorcraft_doc::PageBox::Bleed } else { document::page_box(crop) }; let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, page_box, C); // A blank page has no art's bounds: it keeps its crop box, as opening it as art does. - let (s, bounding) = match read(bounding) { + let (mut s, bounding) = match read(bounding) { Err(e) if bounding && e.to_string().contains("has no art to place") => (read(false)?, false), s => (s?, bounding), }; @@ -283,10 +286,26 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa } cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk); // The document's bleed is the widest of the pages' on each side. - let [top, bottom, left, right] = bleed.unwrap_or_default(); - for (side, b) in doc.setup.bleed.iter_mut().zip([top, bottom, left, right]) { + let sides = bleed.unwrap_or_default(); + for (side, b) in doc.setup.bleed.iter_mut().zip(sides) { *side = side.max(b.min(vectorcraft_doc::setup::MAX_BLEED)); } + for w in std::mem::take(&mut s.warnings) { + if !warnings.contains(&w) { + warnings.push(w); + } + } + read_pages.push((page, s, bounding, sides)); + } + // Each artboard is the gap from the one before plus the bleed on both sides of it: the + // document's bleed, which export adds round every artboard, or a page's own bleed box where it + // reaches farther. Neither page then shows or exports the other's art. + let [_, _, doc_left, doc_right] = doc.setup.bleed; + let mut x = 0.0; + for (i, (page, s, bounding, [top, bottom, left, right])) in read_pages.into_iter().enumerate() { + if i > 0 { + x += doc_left.max(left); + } let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding); if let NodeKind::PlacedDocument(p) = &mut node.kind { p.xf = Affine::translate((x - left, -top)); @@ -299,12 +318,7 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa doc.layers.push(Arc::new(layer)); let rect = Rect::new(x, 0.0, x + s.frame.width() - left - right, s.frame.height() - top - bottom); doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() }); - x = rect.x1 + gap; - for w in s.warnings { - if !warnings.contains(&w) { - warnings.push(w); - } - } + x = rect.x1 + doc_right.max(right) + gap; } // Pages painted mostly in CMYK read as CMYK documents, as opening them as art does: the // document opens in CMYK (with the CMYK default swatches) when most of its pages do. diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 92374e63e7..8b588975c4 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1205,6 +1205,46 @@ fn link_pages_open_an_ai_file_on_its_trim_box_with_its_bleed() { let _ = std::fs::remove_dir_all(&d); } +#[test] +fn link_pages_on_their_trim_boxes_keep_the_bleed_between_the_artboards() { + // Two 100 pt trim boxes: the first with 60 pt of bleed on each side, the second with 80 pt on + // its left, more than the largest document bleed (72 pt). + let entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2)); + let first = PdfPage { + bleed: Some([0.0, 0.0, 220.0, 220.0]), + trim: Some([60.0, 60.0, 160.0, 160.0]), + entries, + ..PdfPage::new(220.0, 220.0, "0 g 0 0 220 220 re f") + }; + let second = PdfPage { + bleed: Some([0.0, 0.0, 240.0, 220.0]), + trim: Some([80.0, 60.0, 180.0, 160.0]), + ..PdfPage::new(240.0, 220.0, "0 g 0 0 240 220 re f") + }; + let d = std::env::temp_dir().join(format!("vc-ailinkpagesgap-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("pages.ai"); + std::fs::write(&path, pdf_with(&[first, second], &["<< /Length 4 >>\nstream\ndata\nendstream"], None)).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.setup.bleed, [60.0, 60.0, 72.0, 60.0], "top, bottom, left, right"); + // The first page's bleed ends at 160; the second's starts 36 pt later, 80 pt left of its trim box. + let boards: Vec<(f64, f64)> = doc.artboards.iter().map(|a| (a.rect.x0, a.rect.x1)).collect(); + assert_eq!(boards, [(0.0, 100.0), (276.0, 376.0)]); + let shown: Vec = doc + .layers + .iter() + .map(|l| match &l.children().unwrap()[0].kind { + NodeKind::PlacedDocument(p) => p.xf.translation().x, + k => panic!("{k:?}"), + }) + .collect(); + assert_eq!(shown, [-60.0, 196.0], "each page's bleed box round its artboard"); + let _ = std::fs::remove_dir_all(&d); +} + /// A page of text alone, as text-only art has in its PDF part: no path is painted. const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; diff --git a/docs/mcp.md b/docs/mcp.md index 4a01284fd6..a100f0bad7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1282,7 +1282,7 @@ placed document linked to that page of the file, as `file.place` with `link` pla Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds; a blank page gets its crop box), in a row from the left 36 pt apart, and a layer named after the page (`Page 2`). A PDF-compatible `.ai` with `cropTo: "crop"` gets its trim boxes, as it does when it opens as art. A page on its trim box shows its bleed box round the artboard, and the pages give the document -their bleed. The document opens in CMYK when most of those pages are painted mostly in +their bleed; the artboards are then 36 pt apart plus the bleed on both sides. The document opens in CMYK when most of those pages are painted mostly in CMYK, unless `colorMode` names a mode. All the pages' objects share one copy of the file. The Links panel lists each page, and `links.embed` makes a page editable art. `document.open` refuses `linkPages` for a file that is not a PDF or a PDF-compatible `.ai`, for a file given as