diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ca74243699..b7eb9ddd96 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), 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), 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/load.rs b/crates/engine/src/cmd/fileio/load.rs index bb9dc3711a..e06b24c4b9 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -677,15 +677,28 @@ impl ReadFile { /// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { 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 { + 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 { diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 2a0dede17f..b0f08ca4c5 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -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 { 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)> { + 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::::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`. diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 8b00e95152..5809990e0f 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -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, + /// 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 { + 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>, + 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) +} + +/// [`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>>, + path: Option<&str>, + shown: (u32, bool, PageBox), + cmd: &str, +) -> Result { + 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 { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index c639fc9232..65808d2360 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -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 = 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"); diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index 1baa838d90..4820680a09 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -2,7 +2,8 @@ //! asks which pages (a preview with page navigation), the box to crop to and the password. //! //! Fields: `mode` (`open` | `place`), `name`, `path?`, `pageCount`, `page` (the page shown, and the -//! one placed; 1-based), `allPages` and `range` (open), `cropTo` and `password`. `__locked` is set +//! one placed; 1-based), `allPages`, `range` and `linkPages` (open: Import PDF Pages as Links, for +//! a file read from a path and opened without a password), `cropTo` and `password`. `__locked` is set //! until the password is accepted: OK then unlocks the file. `__place` keeps the other `file.place` //! params (Link, Template, `at`…). The file's bytes stay out of the fields, in //! [`crate::state::UiState::dialog_file`]. @@ -142,6 +143,7 @@ pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option Option { Some(d.str("password")).filter(|p| !p.is_empty()) } +/// Can the pages open linked: the file was read from a path (a linked page is read from its +/// file again) and opens without a password (`document.open`'s `linkPages` refuses one). +fn can_link(d: &Dialog) -> bool { + d.fields.get("path").and_then(Value::as_str).is_some() && password(d).is_none() +} + +/// Import PDF Pages as Links is checked, and can be. +fn links_on(d: &Dialog) -> bool { + d.bool("linkPages") && can_link(d) +} + /// OK: unlock the file with the password, else open the pages or place the page. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let file = app.ui.dialog_file.clone().ok_or("the PDF is no longer loaded")?; @@ -213,6 +226,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if !d.bool("allPages") && !range.trim().is_empty() { p["pages"] = json!(range); } + if links_on(&d) { + p["linkPages"] = json!(true); + } io::open_document(app, &name, &file.bytes, path, &p).map(|_| ()) }; match r { @@ -291,6 +307,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); widgets::dim_label(ui, tl!("e.g. 1-3, 5")); + ui.add_space(10.0); + if widgets::check(ui, tl!("Import PDF pages as links"), links_on(d), can_link(d)) { + d.fields.insert("linkPages".into(), json!(!d.bool("linkPages"))); + } } }); }); diff --git a/crates/ui-egui/src/dialogs/tests_import_pdf.rs b/crates/ui-egui/src/dialogs/tests_import_pdf.rs index 0a1f5b5f7e..62e783c267 100644 --- a/crates/ui-egui/src/dialogs/tests_import_pdf.rs +++ b/crates/ui-egui/src/dialogs/tests_import_pdf.rs @@ -83,6 +83,47 @@ fn all_pages_and_a_crop_box() { assert_eq!(artboard_widths(&app), [10.0, 40.0, 30.0]); } +/// Each layer's objects in the active document: placed documents (true) or not. +fn placed_pages(app: &VectorcraftApp) -> Vec { + let doc = &app.session.active().unwrap().doc; + doc.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| matches!(n.kind, NodeKind::PlacedDocument(_))).collect() +} + +#[test] +fn import_pdf_pages_as_links_opens_each_page_linked() { + let dir = vectorcraft_testkit::TempDir::new("import-pdf-link-pages"); + let path = dir.join("three.pdf").to_string_lossy().into_owned(); + std::fs::write(&path, three_pages()).unwrap(); + let mut app = app(); + io::open_bytes(&mut app, &path, &three_pages(), Some(path.clone())).unwrap(); + assert_eq!(field(&app, "linkPages"), false, "off by default"); + frame(&mut app); + set(&mut app, "linkPages", json!(true)); + set(&mut app, "allPages", json!(false)); + set(&mut app, "range", json!("1, 3")); + frame(&mut app); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(artboard_widths(&app), [100.0, 300.0]); + assert_eq!(placed_pages(&app), [true, true]); + // A file given without its path, or opened with a password, can't link its pages: they open as art. + io::open_bytes(&mut app, "three.pdf", &three_pages(), None).unwrap(); + set(&mut app, "linkPages", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(placed_pages(&app), [false, false, false]); + let two = pdf(&[PdfPage::new(10.0, 10.0, "0 g 0 0 5 5 re f"), PdfPage::new(20.0, 10.0, "0 g 0 0 5 5 re f")], Some("pw")); + let locked = dir.join("two.pdf").to_string_lossy().into_owned(); + std::fs::write(&locked, &two).unwrap(); + io::open_bytes(&mut app, &locked, &two, Some(locked.clone())).unwrap(); + set(&mut app, "password", json!("pw")); + confirm(&mut app).unwrap(); + set(&mut app, "linkPages", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(placed_pages(&app), [false, false]); +} + #[test] fn single_page_pdfs_open_directly() { let mut app = app(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..ec5682acfb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1630,6 +1630,7 @@ font-technology Variable Променливи Images: Изображения: Import Keyboard Shortcuts… Импортиране на клавишни комбинации… Import PDF Импортиране на PDF + Import PDF pages as links Импортиране на страниците на PDF като връзки Import PDF Presets Импортиране на PDF предварителни настройки Import Perspective Grid Presets Импортиране на предварителни настройки за перспективна мрежа Import Print Presets Импортиране на предварителни настройки за печат diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..fd8dbf7a2f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2598,6 +2598,7 @@ digits Malayalam Malajalam e.g. 1-3, 5 npr. 1-3, 5 First Page Prva stranica Import PDF Uvezi PDF + Import PDF pages as links Uvezi stranice PDF-a kao veze Last Page Posljednja stranica Next Page Sljedeća stranica of {count} od {count} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..9284836b8e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2101,6 +2101,7 @@ digits Malayalam Malaiàlam Import Library… Importa biblioteca… Import Matches Importa coincidències Import PDF Importa PDF + Import PDF pages as links Importa les pàgines del PDF com a enllaços Import PDF Presets Importa preajustos de PDF Import Perspective Grid Presets Importa preajustos de quadrícula de perspectiva Import Print Presets Importa preajustos d’impressió diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..cb4412c964 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2633,6 +2633,7 @@ digits Malayalam Malajálamské e.g. 1-3, 5 např. 1-3, 5 First Page První stránka Import PDF Importovat PDF + Import PDF pages as links Importovat stránky PDF jako propojení Last Page Poslední stránka Next Page Další stránka of {count} z {count} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..f4b8254aa6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2415,6 +2415,7 @@ digits Malayalam Malayalam e.g. 1-3, 5 f.eks. 1-3, 5 First Page Første side Import PDF Importér PDF + Import PDF pages as links Importér PDF-sider som links Last Page Sidste side Next Page Næste side of {count} af {count} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..0a49a92806 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1689,6 +1689,7 @@ file-filter Templates Vorlagen Images: Bilder: Import Keyboard Shortcuts… Tastaturbefehle importieren… Import PDF PDF importieren + Import PDF pages as links PDF-Seiten als Verknüpfungen importieren Import PDF Presets PDF-Vorgaben importieren Import Perspective Grid Presets Vorgaben für Perspektivenraster importieren Import Print Presets Druckvorgaben importieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..0478d0793b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1604,6 +1604,7 @@ font-technology Variable Μεταβλητές Images: Εικόνες: Import Keyboard Shortcuts… Εισαγωγή συντομεύσεων πληκτρολογίου… Import PDF Εισαγωγή PDF + Import PDF pages as links Εισαγωγή σελίδων PDF ως συνδέσμων Import PDF Presets Εισαγωγή προεπιλογών PDF Import Perspective Grid Presets Εισαγωγή προεπιλογών πλέγματος προοπτικής Import Print Presets Εισαγωγή προεπιλογών εκτύπωσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..d2eca97fc7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1685,6 +1685,7 @@ font-technology Variable Variables Images: Imágenes: Import Keyboard Shortcuts… Importar métodos abreviados de teclado… Import PDF Importar PDF + Import PDF pages as links Importar páginas PDF como vínculos Import PDF Presets Importar preajustes de PDF Import Perspective Grid Presets Importar preajustes de cuadrícula de perspectiva Import Print Presets Importar preajustes de impresión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..4a994cf1c7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2577,6 +2577,7 @@ digits Malayalam Malajalam e.g. 1-3, 5 esim. 1-3, 5 First Page Ensimmäinen sivu Import PDF Tuo PDF + Import PDF pages as links Tuo PDF-sivut linkkeinä Last Page Viimeinen sivu Next Page Seuraava sivu of {count} / {count} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..7aa913d284 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1679,6 +1679,7 @@ font-technology Variable Variables Images: Images : Import Keyboard Shortcuts… Importer des raccourcis clavier… Import PDF Importer un PDF + Import PDF pages as links Importer les pages PDF comme liens Import PDF Presets Importer des paramètres prédéfinis PDF Import Perspective Grid Presets Importer des paramètres prédéfinis de grille de perspective Import Print Presets Importer des paramètres prédéfinis d’impression diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..2921a197aa 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1708,6 +1708,7 @@ Import Library લાઇબ્રેરી આયાત કરો Import Matches આયાત મેળ Import PDF PDF આયાત કરો + Import PDF pages as links PDF પેજને લિંક તરીકે આયાત કરો Import PDF Presets PDF આયાત પ્રીસેટ્સ Import Perspective Grid Presets પર્સ્પેક્ટિવ ગ્રિડ પ્રીસેટ્સ આયાત કરો Import Preferences પસંદગીઓ આયાત કરો diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..d3d9409f1b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2329,6 +2329,7 @@ antiAlias Sharp शार्प Import Library… आयात लाइब्रेरी… Import Matches आयात मैच Import PDF PDF आयात करें + Import PDF pages as links PDF पृष्ठों को लिंक के रूप में आयात करें Import PDF Presets PDF प्रीसेट्स इम्पोर्ट करें Import Perspective Grid Presets आयात पर्सपेक्टिव ग्रिड प्रीसेट Import Preset… प्रीसेट इम्पोर्ट करें… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f6100b2f..db72e54062 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2103,6 +2103,7 @@ file-filter Templates Sablonok Import Library Könyvtár importálása Import Library… Könyvtár importálása… Import PDF PDF importálása + Import PDF pages as links PDF-oldalak importálása csatolásként Import PDF Presets PDF-előbeállítások importálása Import Perspective Grid Presets Perspektivikus rács előbeállításainak importálása Import Print Presets Nyomtatási előbeállítások importálása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..92c34768ba 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2415,6 +2415,7 @@ digits Malayalam Malayalam e.g. 1-3, 5 mis. 1-3, 5 First Page Halaman pertama Import PDF Impor PDF + Import PDF pages as links Impor halaman PDF sebagai tautan Last Page Halaman terakhir Next Page Halaman berikutnya of {count} dari {count} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6743704785..f5cf2db813 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1720,6 +1720,7 @@ file-filter Templates Modelli Images: Immagini: Import Keyboard Shortcuts… Importa scelte rapide da tastiera… Import PDF Importa PDF + Import PDF pages as links Importa le pagine PDF come collegamenti Import PDF Presets Importa preset PDF Import Perspective Grid Presets Importa preset griglia prospettica Import Print Presets Importa preset di stampa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9cdb8fcbe..924e25e686 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2071,6 +2071,7 @@ antiAlias Sharp シャープ Images: イメージ: Import Keyboard Shortcuts… キーボードショートカットを読み込み… Import PDF PDF を読み込み + Import PDF pages as links PDF ページをリンクとして読み込み Import PDF Presets PDF プリセットを読み込み Import Perspective Grid Presets 遠近グリッドプリセットを読み込み Import Print Presets プリントプリセットを読み込み diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..7213afe8c6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2339,6 +2339,7 @@ digits Malayalam მალაიალამური e.g. 1-3, 5 მაგ. 1-3, 5 First Page პირველი გვერდი Import PDF PDF-ის იმპორტი + Import PDF pages as links PDF-ის გვერდების იმპორტი ბმულებად Last Page ბოლო გვერდი Next Page შემდეგი გვერდი of {count} / {count} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..1ddc464da2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2061,6 +2061,7 @@ antiAlias Sharp 날카롭게 Images: 이미지: Import Keyboard Shortcuts… 단축키 가져오기… Import PDF PDF 가져오기 + Import PDF pages as links PDF 페이지를 링크로 가져오기 Import PDF Presets PDF 프리셋 가져오기 Import Perspective Grid Presets 원근 격자 프리셋 가져오기 Import Print Presets 인쇄 프리셋 가져오기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..d08895b259 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2933,6 +2933,7 @@ digits Malayalam Malayalam e.g. 1-3, 5 bijv. 1-3, 5 First Page Eerste pagina Import PDF PDF importeren + Import PDF pages as links PDF-pagina's importeren als koppelingen Last Page Laatste pagina Next Page Volgende pagina of {count} van {count} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..f681ddb7c8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2548,6 +2548,7 @@ digits Malayalam Malajalam e.g. 1-3, 5 np. 1-3, 5 First Page Pierwsza strona Import PDF Importuj PDF + Import PDF pages as links Importuj strony PDF jako łącza Last Page Ostatnia strona Next Page Następna strona of {count} z {count} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..367fd623c6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1483,6 +1483,7 @@ antiAlias Sharp Nítido Import Keyboard Shortcuts… Importar Atalhos de Teclado… Import Data Sets… Importar Conjuntos de Dados… Import PDF Importar PDF + Import PDF pages as links Importar páginas do PDF como links Import PDF Presets Importar Predefinidos de PDF Import Perspective Grid Presets Importar Predefinidos de Grade de Perspectiva Import Print Presets Importar Predefinidos de Impressão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..2f30120cf5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1709,6 +1709,7 @@ file-filter Templates Шаблоны Images: Изображения: Import Keyboard Shortcuts… Импортировать сочетания клавиш… Import PDF Импортировать PDF + Import PDF pages as links Импортировать страницы PDF как связи Import PDF Presets Импортировать наборы PDF Import Perspective Grid Presets Импортировать наборы сетки перспективы Import Print Presets Импортировать наборы печати diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..51867c4a71 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1643,6 +1643,7 @@ font-technology Variable Variabla Images: Bilder: Import Keyboard Shortcuts… Importera kortkommandon… Import PDF Importera PDF + Import PDF pages as links Importera PDF-sidor som länkar Import PDF Presets Importera PDF-förinställningar Import Perspective Grid Presets Importera förinställningar för perspektivrutnät Import Print Presets Importera utskriftsförinställningar diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..b9666088ce 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1521,6 +1521,7 @@ font-technology Variable Değişken Images: Görseller: Import Keyboard Shortcuts… Klavye Kısayollarını İçe Aktar… Import PDF PDF'yi içe aktar + Import PDF pages as links PDF sayfalarını bağlantı olarak içe aktar Import PDF Presets PDF Hazır Ayarlarını İçe Aktar Import Perspective Grid Presets Perspektif Izgarası Ön Ayarlarını İçe Aktar Import Print Presets Yazdırma Ön Ayarlarını İçe Aktar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..598693337f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1714,6 +1714,7 @@ file-filter Templates Шаблони Images: Зображення: Import Keyboard Shortcuts… Імпортувати сполучення клавіш… Import PDF Імпортувати PDF + Import PDF pages as links Імпортувати сторінки PDF як зв’язки Import PDF Presets Імпортувати набори PDF Import Perspective Grid Presets Імпортувати набори сітки перспективи Import Print Presets Імпортувати набори друку diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..54410fe1ef 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1955,6 +1955,7 @@ digits Hindi Hindi Import Library Nhập thư viện Import Library… Nhập thư viện… Import PDF Nhập PDF + Import PDF pages as links Nhập các trang PDF dưới dạng liên kết Import PDF Presets Nhập thiết lập sẵn PDF Import Perspective Grid Presets Nhập thiết lập sẵn lưới phối cảnh Import Print Presets Nhập thiết lập sẵn in diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..0c21a6bcf7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1639,6 +1639,7 @@ file-filter Templates 模板 Images: 图像: Import Keyboard Shortcuts… 导入键盘快捷键… Import PDF 导入 PDF + Import PDF pages as links 将 PDF 页面导入为链接 Import PDF Presets 导入 PDF 预设 Import Perspective Grid Presets 导入透视网格预设 Import Print Presets 导入打印预设 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..b2c7dbcc32 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1686,6 +1686,7 @@ file-filter Templates 範本 Images: 影像: Import Keyboard Shortcuts… 匯入鍵盤快速鍵… Import PDF 匯入 PDF + Import PDF pages as links 將 PDF 頁面匯入為連結 Import PDF Presets 匯入 PDF 預設集 Import Perspective Grid Presets 匯入透視格點預設集 Import Print Presets 匯入列印預設集 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5ff38be13..4fd58266e9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -281,7 +281,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | -| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | +| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed; Import PDF pages as links opens each page as a placed document linked to it, on its own artboard, `tests/placed_document.rs`) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` | diff --git a/docs/mcp.md b/docs/mcp.md index 2e43c14d0e..4f7585493b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1248,6 +1248,15 @@ empty `boxes`, and `thumbnail: 1` shows what that page prints. A page whose `Use opens at its size in points, the file's numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size. +`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 +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. +`document.open` refuses `linkPages` for a file that is not a PDF or a PDF-compatible `.ai`, for a file given as +`dataBase64` without a path, with a `password`, and with `textAs: "outlines"`, and rejects a `linkPages` that is not +true or false. Save then asks for a name. `document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number. The error names the param, and nothing opens or is placed. Leaving a param out or giving null keeps its default. @@ -1263,6 +1272,7 @@ CMYK too. ```json {"name":"run_command","arguments":{"command":"document.pdfInfo","params":{"path":"/tmp/brochure.pdf","thumbnail":2,"cropTo":"trim"}}} {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","pages":"2-3","cropTo":"trim"}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","linkPages":true}}} ``` PostScript files (`.eps`, `.epsf`, `.ps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS