Import PDF: Import PDF pages as links opens each page linked to its file

File › Open's Import PDF dialog gets Import PDF pages as links. With it on,
each page picked opens as a placed document linked to that page of the
file, on an artboard of its Crop To box and a layer named after the page,
in place of editable art. The Links panel lists each page, and Embed makes
a page editable.

document.open takes the matching linkPages param. It is refused for a
file that is not a PDF or a PDF-compatible .ai, for bytes without a path,
with a password and with textAs: outlines. The pages share one copy of
the file, and the document opens in CMYK when most of its pages do.
Save asks for a name, as for other partly read PDFs.
This commit is contained in:
dotCooCoo
2026-11-14 12:13:59 -08:00
parent bada8e1710
commit dab13fbf14
37 changed files with 347 additions and 16 deletions
File diff suppressed because one or more lines are too long
+16 -3
View File
@@ -677,15 +677,28 @@ impl ReadFile {
/// ([`super::importer`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
let mut loaded = match super::importer::pick(name, bytes, p)? {
Some(plugin) => super::importer::load(name, bytes, &plugin, p)?,
None => load_with(name, bytes, &opts)?,
let mut loaded = if opts.link_pages {
linked_pages(name, bytes, path.as_deref(), &opts)?
} else {
match super::importer::pick(name, bytes, p)? {
Some(plugin) => super::importer::load(name, bytes, &plugin, p)?,
None => load_with(name, bytes, &opts)?,
}
};
let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
}
/// The PDF `name` (`bytes`, read from `path`) opened with `linkPages`: its pages as placed documents
/// linked to them ([`super::pdfimport::linked_pages`]), named after the file.
fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, opts: &LoadOptions) -> Result<Loaded> {
let (mut doc, warnings) = super::pdfimport::linked_pages(name, bytes, path, opts)?;
doc.title = file_name(name);
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{}`: not a PDF", file_name(name))))?;
Ok(Loaded { doc, format, warnings, restored: false, converted: false, preview_only: false, editing_data: false, native_pdf_compatible: None })
}
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile) -> Result<Value> {
+95 -6
View File
@@ -2,11 +2,15 @@
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
use vectorcraft_doc::{Artboard, ColorMode, Document, LayerColor, LinkInfo, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::colormgmt::Grays;
use super::super::place::document;
use super::super::{Result, Session, bad, check_numbers, number_or, parse_range, text_param};
use super::{err, source};
@@ -47,6 +51,9 @@ pub struct LoadOptions {
/// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries;
/// off, it opens as another app would open it, from the editing copy of its art or its pages.
pub native: bool,
/// Each page of a PDF opens as a placed document linked to that page of its file, on an
/// artboard of its own, instead of as editable art (`linkPages`; see [`linked_pages`]).
pub link_pages: bool,
}
impl Default for LoadOptions {
@@ -65,6 +72,7 @@ impl Default for LoadOptions {
trust_editing_data: false,
legacy_names: vectorcraft_eps::NameEncoding::default(),
native: true,
link_pages: false,
}
}
}
@@ -74,8 +82,8 @@ impl LoadOptions {
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false),
/// `editingData` (true | false | `always`),
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF
/// import options).
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]), `linkPages` (true |
/// false) and `dxf` (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@@ -115,6 +123,10 @@ impl LoadOptions {
.and_then(vectorcraft_eps::NameEncoding::from_label)
.ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?,
};
let link_pages = match p.get("linkPages").filter(|v| !v.is_null()) {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "linkPages must be true or false"))?,
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self {
pages,
@@ -130,14 +142,15 @@ impl LoadOptions {
trust_editing_data,
legacy_names,
native: true,
link_pages,
})
}
/// Does the document read only part of the file, or read it differently (a page range, another
/// box, a password, without its hidden layers, without the VectorCraft document it carries)?
/// Writing it back would lose the rest, so Save doesn't (it asks for a name).
/// box, a password, without its hidden layers, without the VectorCraft document it carries, its
/// pages linked)? Writing it back would lose the rest, so Save doesn't (it asks for a name).
pub fn is_partial(&self) -> bool {
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native || self.link_pages
}
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
@@ -201,6 +214,82 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions {
ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: o.text_as, layers: false, ..Default::default() }
}
/// `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
/// 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
/// `textAs: outlines` (a linked page shows the file's own type) and for a file over
/// [`document::MAX_FILE`] → the document and the notes of reading its pages.
pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
const C: &str = "document.open";
let file = super::file_name(name);
if !document::is_pdf(name, bytes) {
return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file")));
}
let path = path.ok_or_else(|| bad(C, "linkPages needs the file's path: linked pages are read from it"))?;
if o.password.is_some() {
return Err(bad(C, "linkPages can't open a file with a password: linked pages are read again without it"));
}
if o.text_as != TextAs::Text {
return Err(bad(C, "linkPages can't be used with textAs: outlines: linked pages show the file's own type"));
}
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();
let picked = match &o.pages {
None => (0..count).collect(),
Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?,
};
let bounding = o.crop == CropTo::Bounding;
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();
doc.layers.clear();
let link = crate::cmd::links::link_info(path, bytes);
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());
let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::<String>::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)?;
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 {
p.xf = Affine::translate((x, 0.0));
}
node.name = Some(file.clone());
let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children, .. } = &mut layer.kind {
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());
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);
}
}
}
// 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.
if cmyk_pages * 2 > picked.len() {
doc.color_mode = ColorMode::Cmyk;
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
}
if let Some(mode) = o.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, o.grays);
}
Ok((doc, warnings))
}
const INFO: &str = "document.pdfInfo";
/// `document.pdfInfo`.
+40 -4
View File
@@ -20,7 +20,7 @@ use std::sync::Arc;
use vectorcraft_doc::links::hash_bytes;
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_doc::{ColorMode, Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::CropTo;
@@ -221,22 +221,57 @@ pub(crate) struct Source {
/// What is shown, in the file's coordinates: the artboard, the page's box or the art's bounds.
pub frame: Rect,
pub warnings: Vec<String>,
/// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK).
pub color_mode: ColorMode,
}
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
/// artboard `page`, or the PDF or `.ai` file for showing page `page` clipped to its box
/// `page_box` (its art's bounds when `bounding`).
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result<Source> {
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.
pub(crate) fn shared_source(
name: &str,
bytes: &Arc<Vec<u8>>,
path: Option<&str>,
page: u32,
bounding: bool,
page_box: PageBox,
cmd: &str,
) -> Result<Source> {
read_source(name, bytes, Some(bytes), 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.
fn read_source(
name: &str,
bytes: &[u8],
shared: Option<&Arc<Vec<u8>>>,
path: Option<&str>,
shown: (u32, bool, PageBox),
cmd: &str,
) -> Result<Source> {
let (page, bounding, page_box) = shown;
// 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);
// 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();
if !confined && let Some((_, s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() {
if !confined && let Some((_, mut s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() {
// The same bytes: the copy given is kept instead, so the pages that share it go on sharing.
if let Some(b) = shared {
s.blob.bytes = b.clone();
}
return Ok(s);
}
let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?;
let mut blob = ImageBlob::new(mime(name, bytes), bytes.to_vec());
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 {
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
key: key.clone(),
@@ -249,9 +284,10 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo
};
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;
// 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 };
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode };
if !confined {
let mut m = lock_memo();
if m.len() >= MEMO_SIZE {
@@ -599,6 +599,100 @@ fn with_link_a_pdf_page_places_linked() {
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
/// The colour at the centre of `r` in `doc`.
fn colour_at(doc: &Document, r: Rect) -> [u8; 3] {
let img = vectorcraft_render::Renderer::new().render_region(doc, r, 1.0, true);
let [red, g, b, _] = img.pixel((r.width() / 2.0) as u32, (r.height() / 2.0) as u32);
[red, g, b]
}
#[test]
fn link_pages_opens_each_picked_pdf_page_as_a_linked_placed_document() {
let dir = Folder::new("open-pdf-link-pages");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED, BLUE, RED], 0);
let mut s = Session::new();
let r = s.execute("document.open", &json!({"path": src, "linkPages": true, "pages": "2-3"})).unwrap();
assert_eq!((r["format"].as_str(), r["restored"].as_bool()), (Some("pdf"), Some(false)), "{r}");
let st = s.doc().unwrap();
assert_eq!(st.path, None, "Save asks for a name instead of writing over the PDF");
let d = &st.doc;
let boards: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
assert_eq!(boards, [Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(136.0, 0.0, 236.0, 50.0)]);
let layers: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(layers, ["Page 2", "Page 3"]);
let mut ids = vec![];
for (layer, (page, board)) in d.layers.iter().zip([(2, boards[0]), (3, boards[1])]) {
let kids = layer.children().unwrap();
assert_eq!(kids.len(), 1);
let p = placed(&s, kids[0].id);
assert_eq!((p.link.path.as_str(), p.link.page, p.bounding), (src.as_str(), Some(page), false), "{p:?}");
assert_eq!(bounds(&s, kids[0].id), board);
ids.push(kids[0].id);
}
assert!(near(colour_at(d, boards[0]), BLUE));
assert!(near(colour_at(d, boards[1]), RED));
let links = s.execute("links.list", &json!({})).unwrap()["links"].clone();
assert_eq!(links.as_array().map(Vec::len), Some(2), "{links}");
assert!(links.as_array().unwrap().iter().all(|row| row["document"] == true && row["status"] == "ok"), "{links}");
// Embed makes a page editable art.
s.execute("links.embed", &json!({"ids": [ids[0].0]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.node(ids[0]).unwrap().kind_label(), "Clip Group");
assert_eq!(bounds(&s, ids[0]), boards[0]);
assert!(near(colour_at(d, boards[0]), BLUE));
// Every page, with the art's bounds as the artboard.
s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.len(), 3);
assert!(d.layers.iter().flat_map(|l| l.children().unwrap()).all(|n| matches!(&n.kind, NodeKind::PlacedDocument(p) if p.bounding)));
}
#[test]
fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() {
use vectorcraft_testkit::pdf::{PdfPage, pdf};
let dir = Folder::new("open-pdf-link-pages-cmyk");
let src = dir.file("cmyk.pdf");
let page = || PdfPage::new(100.0, 50.0, "0 0.5 1 0 k 0 0 100 50 re f");
write(&src, &pdf(&[page(), page(), page()], None));
let mut s = Session::new();
s.execute("document.open", &json!({"path": src, "linkPages": true})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.color_mode, vectorcraft_doc::ColorMode::Cmyk, "pages painted in CMYK open as a CMYK document");
let blobs: Vec<_> = d.images.values().map(|b| b.bytes.clone()).collect();
assert_eq!(blobs.len(), 3);
assert!(blobs.iter().all(|b| std::sync::Arc::ptr_eq(b, &blobs[0])), "one copy of the file for every page");
s.execute("document.open", &json!({"path": src, "linkPages": true, "colorMode": "rgb"})).unwrap();
assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins");
}
#[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");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED, BLUE], 0);
let bytes = std::fs::read(&src).unwrap();
let logo = dir.file("logo.vectorcraft");
source(&logo, 100.0, 50.0, RED, 0);
let mut s = Session::new();
let cases = [
(json!({"name": "flyer.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "linkPages": true}), "needs the file's path"),
(json!({"path": logo, "linkPages": true}), "isn't a PDF"),
(json!({"path": src, "linkPages": true, "password": "secret"}), "password"),
(json!({"path": src, "linkPages": true, "textAs": "outlines"}), "textAs: outlines"),
(json!({"path": src, "linkPages": "yes"}), "linkPages must be true or false"),
(json!({"path": src, "linkPages": true, "pages": "3"}), "pages:"),
];
for (p, says) in cases {
let e = s.execute("document.open", &p).unwrap_err().to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert!(s.documents().is_empty(), "nothing opened");
// Off, the pages open as editable art.
s.execute("document.open", &json!({"path": src, "linkPages": false})).unwrap();
assert!(!s.doc().unwrap().doc.has_placed());
}
#[test]
fn a_changed_pdf_is_modified_and_update_links_reads_it_again() {
let dir = Folder::new("placed-pdf-update");