From e2043419968d068831c76a496c505c9cc085e35b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:31:48 +0700 Subject: [PATCH 1/3] =?UTF-8?q?M4.45:=20PDF=20import=20and=20place=20optio?= =?UTF-8?q?ns=20=E2=80=94=20pages,=20Crop=20To=20and=20password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening or placing a PDF or .ai with several pages or a password shows the Import PDF / Place PDF dialog: a page preview with page navigation, Pages (All or a range) when opening, Crop To (Bounding Box, Art, Crop, Trim, Bleed, Media) and a password prompt for encrypted files. - vectorcraft-pdf: ImportOptions.pages / crop / password, CropTo, info() (pages, sizes, rotation, boxes); NeedsPassword / WrongPassword / BadPage errors; images keyed by content so placed pages merge safely. - document.open takes pages, page, cropTo (or crop) and password; document.pdfInfo (query) lists pages and boxes, reports needsPassword and renders a page thumbnail. - file.place and file.place.info read only the page placed and take every PDF box as crop (art, trim, bleed, media besides crop and bounding) and a password; the UI's File > Place asks with the dialog when no page is given, keeping Link, Template and the other place params. - testkit::pdf writes hand-made PDFs with page boxes and RC4 encryption. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/src/cmd/fileio/load.rs | 24 +- crates/engine/src/cmd/fileio/mod.rs | 19 +- crates/engine/src/cmd/fileio/pdfimport.rs | 106 +++++++ .../engine/src/cmd/fileio/tests_pdfimport.rs | 148 ++++++++++ crates/engine/src/cmd/place/mod.rs | 36 ++- crates/engine/tests/import_fuzz.rs | 52 ++++ crates/pdf/src/import.rs | 45 +-- crates/pdf/src/lib.rs | 38 ++- crates/pdf/src/pages.rs | 109 +++++++ crates/pdf/src/settings.rs | 9 +- crates/pdf/src/tests_import_options.rs | 150 ++++++++++ crates/testkit/src/lib.rs | 2 + crates/testkit/src/pdf.rs | 189 ++++++++++++ crates/ui-egui/Cargo.toml | 3 + .../ui-egui/src/dialogs/export_for_screens.rs | 5 +- crates/ui-egui/src/dialogs/import_pdf.rs | 276 ++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 9 +- .../ui-egui/src/dialogs/tests_import_pdf.rs | 166 +++++++++++ crates/ui-egui/src/io.rs | 11 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/place.rs | 4 + crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/widgets.rs | 16 + docs/control-protocol.md | 2 + docs/mcp.md | 10 + 26 files changed, 1381 insertions(+), 56 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/pdfimport.rs create mode 100644 crates/engine/src/cmd/fileio/tests_pdfimport.rs create mode 100644 crates/pdf/src/pages.rs create mode 100644 crates/pdf/src/tests_import_options.rs create mode 100644 crates/testkit/src/pdf.rs create mode 100644 crates/ui-egui/src/dialogs/import_pdf.rs create mode 100644 crates/ui-egui/src/dialogs/tests_import_pdf.rs diff --git a/Cargo.lock b/Cargo.lock index a848b276..1a37aae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4350,6 +4350,7 @@ dependencies = [ "vectorcraft-pdf", "vectorcraft-render", "vectorcraft-svg", + "vectorcraft-testkit", "vectorcraft-text", "vectorcraft-tools", ] diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 13d4cb4b..24acb1b5 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -8,6 +8,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use super::super::*; +use super::pdfimport::LoadOptions; use super::{Format, format, format_for_name, read_file}; use crate::EngineError; @@ -30,7 +31,7 @@ pub struct RasterImage { pub ppi: Option<(f64, f64)>, } -fn err(e: impl std::fmt::Display) -> EngineError { +pub(super) fn err(e: impl std::fmt::Display) -> EngineError { EngineError::Other(e.to_string()) } @@ -74,6 +75,11 @@ pub const EDITING_DAMAGED: &str = "this SVG's editing data can't be read: it ope /// Read a file of any readable format (`name`: its file name or path, for the extension and title). pub fn load(name: &str, bytes: &[u8]) -> Result { + load_with(name, bytes, &LoadOptions::default()) +} + +/// [`load`] with `document.open` options (the PDF pages, box and password). +pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result { let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; let title = file_name(name); let mut warnings = vec![]; @@ -102,9 +108,9 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { } } "pdf" | "ai" | "ait" => { - let r = vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(err)?; - warnings = r.warnings; - r.document + let (d, w) = super::pdfimport::import(bytes, opts)?; + warnings = w; + d } _ if format.raster => raster_doc(&title, bytes)?, _ => return Err(err(format!("{} files can't be opened yet", format.label))), @@ -120,7 +126,13 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { /// Open a file's bytes as the new active document (what `document.open` does) → /// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file. pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option) -> Result { - let Loaded { mut doc, format, warnings } = load(name, bytes)?; + open_bytes_with(s, name, bytes, path, &Value::Null) +} + +/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]). +pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { + let opts = LoadOptions::from_params("document.open", p)?; + let Loaded { mut doc, format, warnings } = load_with(name, bytes, &opts)?; // A template (saved by Save as Template, or an .ait file) opens as a new untitled document. let template = doc.template || format.id == "ait"; if template { @@ -155,7 +167,7 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result> { pub(super) fn open(s: &mut Session, p: &Value) -> Result { let src = source(p, "document.open")?; - open_bytes(s, src.name, &src.bytes, src.path.map(str::to_string)) + open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p) } /// Decode an image's header (and, for formats stored as PNG, its pixels). diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 7d89c7f9..6a39d564 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -7,6 +7,7 @@ //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`. //! - `save`: `document.save`, `file.saveAsTemplate`. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. +//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -16,6 +17,7 @@ mod encode; mod export; mod load; pub mod pdf; +mod pdfimport; pub mod ppi; mod save; mod svg; @@ -24,8 +26,10 @@ use serde_json::{Value, json}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background}; +use load::err; pub(crate) use load::source; -pub use load::{Loaded, RasterImage, detect, file_name, load, open_bytes, raster_image}; +pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image}; +pub use pdfimport::{LoadOptions, page_document}; pub use save::{save_encoding, save_format}; pub use svg::options_map as svg_options; @@ -39,7 +43,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64} → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document", always, load::open ), @@ -107,6 +111,15 @@ pub fn specs() -> Vec { always, formats ), + cmd!( + query "document.pdfInfo", + "PDF Info", + [], + None, + "{path} or {name?, dataBase64}, password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box) → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; an encrypted PDF without its password → {pages: 0, needsPassword: true}", + always, + pdfimport::pdf_info + ), ] } @@ -455,6 +468,8 @@ mod tests; #[cfg(test)] mod tests_pdf; #[cfg(test)] +mod tests_pdfimport; +#[cfg(test)] mod tests_svg; #[cfg(test)] mod tests_svgedit; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs new file mode 100644 index 00000000..9bec318e --- /dev/null +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -0,0 +1,106 @@ +//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open` +//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages, +//! boxes, a thumbnail) for the Import PDF dialog. + +use serde_json::{Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError}; + +use super::super::*; +use super::{err, source}; + +/// The `document.open` options besides the file. PDF only for now; other formats ignore them. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LoadOptions { + /// 1-based pages to import, such as `"2-3, 5"`; `None` = every page. + pub pages: Option, + /// The box each page's artboard (or placed frame) gets. + pub crop: CropTo, + /// The password of an encrypted PDF. + pub password: Option, +} + +impl LoadOptions { + /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when + /// `pages` isn't given), `cropTo` (or `crop`) and `password`. + 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, + Some(Value::String(s)) if s.trim().eq_ignore_ascii_case("all") => None, + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Number(n)) => Some(n.to_string()), + Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))), + }; + let crop = match str_param(p, "cropTo").or_else(|| str_param(p, "crop")) { + None => CropTo::default(), + Some(c) => CropTo::ALL + .iter() + .zip(CropTo::IDS) + .find(|(_, id)| id.eq_ignore_ascii_case(c)) + .map(|(c, _)| *c) + .ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?, + }; + let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string); + Ok(Self { pages, crop, password }) + } + + /// The PDF import options: `pages` resolved against the file's page count. + fn import_options(&self, bytes: &[u8]) -> Result { + let pages = match &self.pages { + None => None, + Some(range) => { + let count = vectorcraft_pdf::info(bytes, self.password.as_deref()).map_err(err)?.pages.len(); + Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?) + } + }; + Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), ..Default::default() }) + } +} + +/// Import the pages `o` pick → the document and import notes. +pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec)> { + let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?; + Ok((r.document, r.warnings)) +} + +/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and the +/// import notes. +pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { + let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), ..Default::default() }; + vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err) +} + +const INFO: &str = "document.pdfInfo"; + +/// `document.pdfInfo`. +pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result { + let bytes = source(p, INFO)?.bytes; + let o = LoadOptions::from_params(INFO, p)?; + let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) { + Ok(i) => i, + Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => { + return Ok(json!({"pages": 0, "needsPassword": true, "wrongPassword": e == PdfError::WrongPassword, "pageInfo": []})); + } + Err(e) => return Err(err(e)), + }; + let r4 = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let pages: Vec = info + .pages + .iter() + .map(|pg| { + let boxes: serde_json::Map = pg.boxes.iter().map(|(c, r)| (c.id().to_string(), r4(*r))).collect(); + json!({"width": pg.width, "height": pg.height, "rotation": pg.rotation, "boxes": boxes}) + }) + .collect(); + let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages}); + if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) { + let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len()); + let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?; + let (doc, _) = page_document(&bytes, page, &o)?; + let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0); + let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?; + let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?; + out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png)); + } + Ok(out) +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs new file mode 100644 index 00000000..4d10b31e --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -0,0 +1,148 @@ +//! `document.open` PDF options (pages, Crop To, password) and `document.pdfInfo`. + +use serde_json::{Value, json}; +use vectorcraft_testkit::pdf::{PdfPage, pdf}; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// Three pages of widths 100, 200 and 300 pt, the second with every box set. +fn three_pages() -> Vec { + let mut second = PdfPage::new(200.0, 400.0, "0 0 1 rg 20 30 40 50 re f"); + second.crop = Some([10.0, 10.0, 190.0, 390.0]); + second.bleed = Some([5.0, 5.0, 195.0, 395.0]); + second.trim = Some([15.0, 15.0, 185.0, 385.0]); + second.art = Some([20.0, 20.0, 120.0, 220.0]); + pdf(&[PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), second, PdfPage::new(300.0, 100.0, "")], None) +} + +fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result { + let mut p = json!({"name": "pages.pdf", "dataBase64": b64(bytes)}); + if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) { + o.extend(e); + } + s.execute("document.open", &p) +} + +fn sizes(s: &Session) -> Vec<(f64, f64)> { + s.active().unwrap().doc.artboards.iter().map(|a| (a.rect.width(), a.rect.height())).collect() +} + +#[test] +fn document_open_imports_the_pages_named() { + let bytes = three_pages(); + let mut s = Session::new(); + open(&mut s, &bytes, json!({"pages": "2-3"})).unwrap(); + assert_eq!(sizes(&s), [(180.0, 380.0), (300.0, 100.0)]); + let layers: Vec = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(layers, ["Page 2", "Page 3"]); + open(&mut s, &bytes, json!({"page": 1})).unwrap(); + assert_eq!(sizes(&s), [(100.0, 100.0)]); + open(&mut s, &bytes, json!({"pages": "all"})).unwrap(); + assert_eq!(sizes(&s).len(), 3); + let e = open(&mut s, &bytes, json!({"pages": "4"})).unwrap_err().to_string(); + assert!(e.contains("pages") && e.contains("1 to 3"), "{e}"); + assert!(open(&mut s, &bytes, json!({"pages": true})).is_err()); +} + +#[test] +fn document_open_crops_each_page_to_the_box_asked_for() { + let bytes = three_pages(); + let mut s = Session::new(); + for (crop, size) in [ + ("media", (200.0, 400.0)), + ("crop", (180.0, 380.0)), + ("bleed", (190.0, 390.0)), + ("trim", (170.0, 370.0)), + ("art", (100.0, 200.0)), + ("bounding", (40.0, 50.0)), + ] { + open(&mut s, &bytes, json!({"page": 2, "cropTo": crop})).unwrap(); + assert_eq!(sizes(&s), [size], "{crop}"); + } + // `crop` is an alias (the file.place name); case doesn't matter. + open(&mut s, &bytes, json!({"page": 2, "crop": "Art"})).unwrap(); + assert_eq!(sizes(&s), [(100.0, 200.0)]); + let e = open(&mut s, &bytes, json!({"cropTo": "page"})).unwrap_err().to_string(); + assert!(e.contains("bounding, art, crop, trim, bleed, media"), "{e}"); +} + +#[test] +fn an_encrypted_pdf_needs_its_password() { + let bytes = pdf(&[PdfPage::new(100.0, 50.0, "1 0 0 rg 0 0 20 20 re f"), PdfPage::new(100.0, 50.0, "")], Some("open sesame")); + let mut s = Session::new(); + let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string(); + assert!(e.contains("password-protected"), "{e}"); + let e = open(&mut s, &bytes, json!({"password": "nope"})).unwrap_err().to_string(); + assert!(e.contains("password is wrong"), "{e}"); + assert!(s.active().is_none(), "nothing opened"); + let r = open(&mut s, &bytes, json!({"password": "open sesame", "pages": "1"})).unwrap(); + assert_eq!(r["format"], "pdf"); + assert_eq!(sizes(&s), [(100.0, 50.0)]); + assert_eq!(s.active().unwrap().doc.node_count(), 2, "the layer and its decrypted rectangle"); +} + +#[test] +fn pdf_info_lists_pages_boxes_and_a_thumbnail() { + let bytes = three_pages(); + let mut s = Session::new(); + let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 2, "thumbnailSize": 100, "cropTo": "art"})).unwrap(); + assert_eq!(v["pages"], 3); + assert_eq!(v["needsPassword"], false); + let p2 = &v["pageInfo"][1]; + assert_eq!((p2["width"].as_f64(), p2["height"].as_f64()), (Some(180.0), Some(380.0))); + assert_eq!(p2["boxes"]["trim"], json!([15.0, 15.0, 185.0, 385.0])); + assert_eq!(p2["boxes"]["media"], json!([0.0, 0.0, 200.0, 400.0])); + let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap(); + let img = image::load_from_memory(&png).unwrap(); + assert_eq!((img.width(), img.height()), (50, 100), "the art box at 100 px on its longest side"); + assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 4})).is_err()); + // Locked: no pages until the password is given. + let locked = pdf(&[PdfPage::new(10.0, 10.0, "")], Some("pw")); + let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked)})).unwrap(); + assert_eq!((v["pages"].as_u64(), v["needsPassword"].as_bool(), v["wrongPassword"].as_bool()), (Some(0), Some(true), Some(false))); + let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked), "password": "x"})).unwrap(); + assert_eq!(v["wrongPassword"], true); + let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked), "password": "pw"})).unwrap(); + assert_eq!((v["pages"].as_u64(), v["needsPassword"].as_bool()), (Some(1), Some(false))); + assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(b"not a pdf")})).is_err()); + assert!(s.execute("document.pdfInfo", &json!({})).is_err()); +} + +#[test] +fn file_place_crops_a_pdf_page_to_any_box_and_takes_its_password() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let place = |s: &mut Session, bytes: &[u8], extra: Value| { + let mut p = json!({"name": "pages.pdf", "dataBase64": b64(bytes)}); + if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) { + o.extend(e); + } + s.execute("file.place", &p) + }; + let bytes = three_pages(); + for (crop, size) in + [("trim", (170.0, 370.0)), ("art", (100.0, 200.0)), ("media", (200.0, 400.0)), ("crop", (180.0, 380.0)), ("bounding", (40.0, 50.0))] + { + let r = place(&mut s, &bytes, json!({"page": 2, "crop": crop})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(size.0), Some(size.1)), "{crop}"); + } + let info = s.execute("file.place.info", &json!({"name": "pages.pdf", "dataBase64": b64(&bytes), "page": 2, "crop": "bleed"})).unwrap(); + assert_eq!((info["width"].as_f64(), info["height"].as_f64()), (Some(190.0), Some(390.0))); + let locked = pdf(&[PdfPage::new(60.0, 30.0, "0 g 0 0 10 10 re f")], Some("pw")); + assert!(place(&mut s, &locked, json!({})).unwrap_err().to_string().contains("password-protected")); + assert_eq!(place(&mut s, &locked, json!({"password": "pw"})).unwrap()["width"].as_f64(), Some(60.0)); +} + +#[test] +fn load_options_read_pages_crop_and_password() { + let o = LoadOptions::from_params("x", &json!({"page": 3, "crop": "trim", "password": "p"})).unwrap(); + assert_eq!(o, LoadOptions { pages: Some("3".into()), crop: vectorcraft_pdf::CropTo::Trim, password: Some("p".into()) }); + // `pages` wins over `page`; an empty password is none. + let o = LoadOptions::from_params("x", &json!({"pages": "1-2", "page": 3, "password": ""})).unwrap(); + assert_eq!((o.pages.as_deref(), o.password), (Some("1-2"), None)); + assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default()); +} diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 02785957..d42f52fe 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -6,7 +6,8 @@ //! `link`. //! - An SVG becomes one group of its art. //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page -//! (`crop: "crop"`) or bounded by its art (`crop: "bounding"`). +//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art +//! (`crop: "bounding"`). //! //! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was, //! with its transform; Template puts it on a new template layer. The images, symbols, patterns and @@ -21,6 +22,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_pdf::CropTo; use super::fileio::{self, Format, RasterImage}; use super::*; @@ -40,7 +42,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", has_doc, place ), @@ -49,7 +51,7 @@ pub fn specs() -> Vec { "Placed File Info", [], None, - "{path | name+dataBase64, page?, crop?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", + "{path | name+dataBase64, page?, crop?, password?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", always, info ), @@ -116,11 +118,15 @@ fn load(p: &Value, cmd: &str) -> Result { None => 1, Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))? as usize, }; - let crop = match str_param(p, "crop").unwrap_or("crop") { - "crop" => true, - "bounding" => false, - other => return Err(bad(cmd, format!("crop `{other}`: use \"crop\" or \"bounding\""))), - }; + let opts = fileio::LoadOptions::from_params(cmd, p)?; + let pdf = matches!(format.id, "pdf" | "ai" | "ait"); + if !pdf && !matches!(opts.crop, CropTo::Crop | CropTo::Bounding) { + return Err(bad( + cmd, + format!("crop `{}`: only PDF pages have art, trim, bleed and media boxes; use \"crop\" or \"bounding\"", opts.crop.id()), + )); + } + let crop = opts.crop != CropTo::Bounding; let path = src.path.map(str::to_string); if format.raster { let img = fileio::raster_image(&src.bytes)?; @@ -129,11 +135,11 @@ fn load(p: &Value, cmd: &str) -> Result { return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], path }); } let (mut doc, warnings) = match format.id { - // Only the pages up to the one placed are read. + // Only the page placed is read, its artboard the box asked for (Bounding Box: the art's + // bounds, below). "pdf" | "ai" | "ait" => { - let opts = vectorcraft_pdf::ImportOptions { max_pages: Some(page), ..Default::default() }; - let r = vectorcraft_pdf::import_with_report(&src.bytes, &opts).map_err(|e| bad(cmd, format!("{name}: {e}")))?; - (r.document, r.warnings) + let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts }; + fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))? } _ => { let l = fileio::load(src.name, &src.bytes)?; @@ -145,14 +151,16 @@ fn load(p: &Value, cmd: &str) -> Result { let (nodes, clip) = if matches!(format.id, "svg" | "svgz") { (art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None) } else { + // The page placed is a PDF import's only one. + let index = if pdf { 0 } else { page - 1 }; let n = doc.artboards.len(); - let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| { + let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| { let what = if format.id == "vectorcraft" { "artboard" } else { "page" }; bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)")) })?; // A PDF import has one layer per page; a native document's art is whatever lies on the // artboard. - let pages: Vec<&Arc> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(page - 1).into_iter().collect() }; + let pages: Vec<&Arc> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(index).into_iter().collect() }; let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)); (nodes, crop.then_some(board)) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 20f2aa12..4ba505bb 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -328,6 +328,58 @@ proptest! { } } +/// A page box with junk in it: inverted, empty, huge, NaN or outside the media box. +fn arb_box() -> impl Strategy> { + prop_oneof![ + Just(None), + Just(Some([10.0, 10.0, 90.0, 90.0])), + Just(Some([90.0, 90.0, 10.0, 10.0])), + Just(Some([0.0, 0.0, 0.0, 0.0])), + Just(Some([-1e308, -1e308, 1e308, 1e308])), + Just(Some([f64::NAN, 0.0, 50.0, f64::INFINITY])), + Just(Some([200.0, 200.0, 300.0, 300.0])), + ] +} + +proptest! { + #![proptest_config(config())] + + /// Every page box, rotation, page pick, password and Crop To, through `document.open` and + /// `document.pdfInfo` (with a thumbnail). + #[test] + fn pdf_import_options_never_panic( + boxes in prop::collection::vec(arb_box(), 5), + rotate in prop::sample::select(vec![0, 90, 180, 270, 45, -90, 1_000_000_000]), + encrypt in any::(), + password in prop::sample::select(vec!["", "pw", "wrong", "\u{0}\u{ff}"]), + pages in prop::sample::select(vec!["1", "2", "1-2", "2-1", "0", "-", "1-", "all", "1,1,2", "99999999999999999999"]), + crop in prop::sample::select(vec!["bounding", "art", "crop", "trim", "bleed", "media", "nope"]), + ) { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let page = PdfPage { + media: boxes[0].unwrap_or([0.0, 0.0, 100.0, 100.0]), + crop: boxes[1], + bleed: boxes[2], + trim: boxes[3], + art: boxes[4], + rotate, + ..PdfPage::new(0.0, 0.0, "0 0 1 rg 20 20 50 50 re f") + }; + let bytes = pdf(&[page.clone(), page], encrypt.then_some("pw")); + let b64 = vectorcraft_format::base64_encode(&bytes); + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let p = json!({"name": "x.pdf", "dataBase64": b64, "pages": pages, "cropTo": crop, "password": password, "thumbnail": 1, "thumbnailSize": 64}); + let _ = s.execute("document.pdfInfo", &p); + if s.execute("document.open", &p).is_ok() { + let d = (*s.doc().unwrap().doc).clone(); + survive("pdf options", || Some(d)).unwrap(); + } + }); + r.map_err(|msg| TestCaseError::fail(format!("{pages} {crop} {rotate}: panicked: {msg}")))?; + } +} + // ---------- library files ---------- /// The `data` of a library file `cmd` writes for the rich document. diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index ab1e3cd4..88f0198d 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -10,7 +10,6 @@ use hayro_interpret::{ ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode, SoftMask, StrokeProps, interpret_page, }; -use hayro_syntax::Pdf; use hayro_syntax::object::Name; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; @@ -20,7 +19,7 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{FillRule, PathData}; -use crate::{ImportOptions, ImportReport, PdfError}; +use crate::{CropTo, ImportOptions, ImportReport, PdfError}; /// Import a PDF (or PDF-compatible `.ai`) with default options. pub fn import(bytes: &[u8]) -> Result { @@ -29,12 +28,9 @@ pub fn import(bytes: &[u8]) -> Result { /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { - let pdf = Pdf::new(bytes.to_vec()).map_err(|e| PdfError::Parse(format!("{e:?}")))?; + let pdf = crate::pages::open(bytes, opts.password.as_deref())?; let pages = pdf.pages(); - let n = opts.max_pages.map_or(pages.len(), |m| m.min(pages.len())); - if n == 0 { - return Err(PdfError::NoPages); - } + let picked = crate::pages::picked(opts, pages.len())?; let sink: Arc>> = Arc::default(); let sink2 = sink.clone(); @@ -61,11 +57,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result, /// Horizontal gap in points between the artboards created for consecutive pages. pub artboard_gap: f64, + /// 0-based pages to import, in this order; `None` = every page. + pub pages: Option>, + /// The page box each artboard gets. + pub crop: CropTo, + /// The (user) password of an encrypted PDF. + pub password: Option, } impl Default for ImportOptions { fn default() -> Self { - Self { max_pages: None, artboard_gap: 36.0 } + Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None } } } +settings::choice! { + /// The page box an imported or placed page is cropped to: its artboard (or placed frame). + CropTo { + /// The bounds of the page's art. + Bounding = "bounding", "Bounding Box"; + Art = "art", "Art"; + /// The visible page area (what viewers show). + Crop = "crop", "Crop"; + Trim = "trim", "Trim"; + Bleed = "bleed", "Bleed"; + /// The whole sheet. + Media = "media", "Media"; + } default Crop +} + /// Result of an import with non-fatal warnings (unsupported features, skipped content). #[derive(Clone, Debug)] pub struct ImportReport { @@ -91,6 +115,12 @@ pub enum PdfError { NoPages, #[error("invalid PDF setting: {0}")] BadSetting(String), + #[error("the PDF is password-protected: give its password")] + NeedsPassword, + #[error("the PDF password is wrong")] + WrongPassword, + #[error("page {0} does not exist (the PDF has {1})")] + BadPage(usize, usize), } #[cfg(test)] @@ -108,6 +138,8 @@ mod tests_focal; #[cfg(test)] mod tests_fx; #[cfg(test)] +mod tests_import_options; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stroke; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs new file mode 100644 index 00000000..6e674459 --- /dev/null +++ b/crates/pdf/src/pages.rs @@ -0,0 +1,109 @@ +//! The pages of a PDF to import: opening with a password, the pages picked, the box each page +//! is cropped to ([`CropTo`]) and [`info`]. + +use hayro_syntax::page::Page; +use hayro_syntax::{DecryptionError, LoadPdfError, Pdf}; +use kurbo::{Affine, Rect}; + +use crate::{Choice, CropTo, ImportOptions, PdfError}; + +/// What `info` tells about one page. +#[derive(Clone, Debug, PartialEq)] +pub struct PageInfo { + /// The page's size in points as viewers show it (its crop box, rotated). + pub width: f64, + pub height: f64, + /// The page's rotation in degrees (0, 90, 180 or 270). + pub rotation: u16, + /// Every box but Bounding, in PDF user space (y up), clipped to the media box. A missing + /// bleed, trim or art box is the crop box. + pub boxes: Vec<(CropTo, Rect)>, +} + +/// The pages of a PDF ([`info`]). +#[derive(Clone, Debug, PartialEq)] +pub struct PdfInfo { + pub pages: Vec, +} + +/// Read a PDF, decrypting it with `password` (none: the empty user password). +pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { + let password = password.unwrap_or_default(); + Pdf::new_with_password(bytes.to_vec(), password).map_err(|e| match e { + LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword, + LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword, + e => PdfError::Parse(format!("{e:?}")), + }) +} + +/// The pages and their boxes. An encrypted PDF needs its `password` +/// ([`PdfError::NeedsPassword`] / [`PdfError::WrongPassword`]). +pub fn info(bytes: &[u8], password: Option<&str>) -> Result { + let pdf = open(bytes, password)?; + let pages = pdf + .pages() + .iter() + .map(|page| { + let (w, h) = page.render_dimensions(); + let rotation = match page.rotation() { + hayro_syntax::page::Rotation::None => 0, + hayro_syntax::page::Rotation::Horizontal => 90, + hayro_syntax::page::Rotation::Flipped => 180, + hayro_syntax::page::Rotation::FlippedHorizontal => 270, + }; + let boxes = CropTo::ALL.iter().filter(|c| **c != CropTo::Bounding).map(|c| (*c, page_box(page, *c))).collect(); + PageInfo { width: w as f64, height: h as f64, rotation, boxes } + }) + .collect(); + Ok(PdfInfo { pages }) +} + +/// The 0-based pages `opts` pick out of `count`. +pub(crate) fn picked(opts: &ImportOptions, count: usize) -> Result, PdfError> { + let mut v = match &opts.pages { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= count) { + return Err(PdfError::BadPage(bad.saturating_add(1), count)); + } + p.clone() + } + None => (0..count).collect(), + }; + if let Some(m) = opts.max_pages { + v.truncate(m); + } + if v.is_empty() { + return Err(PdfError::NoPages); + } + Ok(v) +} + +fn rect(r: hayro_syntax::object::Rect) -> Rect { + Rect::new(r.x0, r.y0, r.x1, r.y1).abs() +} + +/// Box `which` of `page` in PDF user space, clipped to the media box (Bounding: the crop box, +/// which the art's bounds replace after import). A missing or empty box is the crop box. +pub(crate) fn page_box(page: &Page<'_>, which: CropTo) -> Rect { + let media = rect(page.media_box()); + let crop = rect(page.intersected_crop_box()); + let key: &[u8] = match which { + CropTo::Bounding | CropTo::Crop => return crop, + CropTo::Media if media.area() > 0.0 => return media, + CropTo::Media => return crop, + CropTo::Bleed => b"BleedBox", + CropTo::Trim => b"TrimBox", + CropTo::Art => b"ArtBox", + }; + page.raw().get::(key).map(|r| rect(r).intersect(media)).filter(|r| r.area() > 0.0).unwrap_or(crop) +} + +/// Where `page` draws on its artboard: the page transform (PDF user space → y-down page space +/// with the crop box at the origin, rotation applied) and `which` box in that space (a page with +/// no area gets the size viewers give it). +pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) { + let init = Affine::new(page.initial_transform(true).as_coeffs()); + let b = init.transform_rect_bbox(page_box(page, which)); + let (w, h) = page.render_dimensions(); + (init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) }) +} diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 4738f323..63304585 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -12,7 +12,7 @@ pub trait Choice: Copy + 'static { const ALL: &'static [Self]; /// The JSON ids, in display order. const IDS: &'static [&'static str]; - /// The labels shown in the Save PDF dialog, in display order. + /// The labels shown in the PDF dialogs, in display order. const LABELS: &'static [&'static str]; } @@ -20,19 +20,19 @@ pub trait Choice: Copy + 'static { macro_rules! choice { ($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = $id:literal, $label:literal;)+ } default $d:ident) => { $(#[$m])* - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum $name { $($(#[$vm])* #[serde(rename = $id)] $v,)+ } - impl Choice for $name { + impl $crate::Choice for $name { const ALL: &'static [Self] = &[$(Self::$v),+]; const IDS: &'static [&'static str] = &[$($id),+]; const LABELS: &'static [&'static str] = &[$($label),+]; } impl $name { - /// The label shown in the Save PDF dialog. + /// The label shown in the PDF dialogs. pub fn label(self) -> &'static str { match self { $(Self::$v => $label,)+ @@ -54,6 +54,7 @@ macro_rules! choice { } }; } +pub(crate) use choice; choice! { /// The PDF standard the file conforms to. diff --git a/crates/pdf/src/tests_import_options.rs b/crates/pdf/src/tests_import_options.rs new file mode 100644 index 00000000..43f31738 --- /dev/null +++ b/crates/pdf/src/tests_import_options.rs @@ -0,0 +1,150 @@ +//! Import options: the pages picked, the box each artboard gets (Crop To), passwords and `info`. + +use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_geom::Rect; +use vectorcraft_testkit::pdf::{PdfPage, pdf}; + +use crate::*; + +/// A 600 × 800 page with every box set and a 200 × 100 rectangle at (120, 150) (PDF space). +fn boxed_page() -> PdfPage { + PdfPage { + crop: Some([50.0, 50.0, 550.0, 750.0]), + bleed: Some([40.0, 40.0, 560.0, 760.0]), + trim: Some([60.0, 60.0, 540.0, 740.0]), + art: Some([100.0, 100.0, 300.0, 400.0]), + ..PdfPage::new(600.0, 800.0, "0 0 1 rg 120 150 200 100 re f") + } +} + +fn open(bytes: &[u8], opts: ImportOptions) -> Document { + import_with_report(bytes, &opts).unwrap().document +} + +fn art(d: &Document) -> Rect { + d.art_bounds().unwrap() +} + +fn close(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 1e-6) +} + +#[test] +fn each_crop_box_sizes_the_artboard_and_places_the_art_in_it() { + let bytes = pdf(&[boxed_page()], None); + // (box, artboard size, the rectangle's top-left on the artboard). + let cases = [ + (CropTo::Media, (600.0, 800.0), (120.0, 550.0)), + (CropTo::Crop, (500.0, 700.0), (70.0, 500.0)), + (CropTo::Bleed, (520.0, 720.0), (80.0, 510.0)), + (CropTo::Trim, (480.0, 680.0), (60.0, 490.0)), + (CropTo::Art, (200.0, 300.0), (20.0, 150.0)), + (CropTo::Bounding, (200.0, 100.0), (0.0, 0.0)), + ]; + for (crop, (w, h), (x, y)) in cases { + let d = open(&bytes, ImportOptions { crop, ..Default::default() }); + let ab = d.artboards[0].rect; + assert!((ab.width() - w).abs() < 1e-6 && (ab.height() - h).abs() < 1e-6, "{crop:?}: {ab:?}"); + let r = art(&d); + assert!(close(r, Rect::new(ab.x0 + x, ab.y0 + y, ab.x0 + x + 200.0, ab.y0 + y + 100.0)), "{crop:?}: art {r:?} on {ab:?}"); + } + // The default is the crop box, as before. + assert_eq!(ImportOptions::default().crop, CropTo::Crop); +} + +#[test] +fn missing_boxes_fall_back_to_the_crop_box_and_boxes_are_clipped_to_the_media_box() { + let page = PdfPage { crop: Some([10.0, 10.0, 290.0, 390.0]), art: Some([-50.0, 0.0, 100.0, 100.0]), ..PdfPage::new(300.0, 400.0, "") }; + let bytes = pdf(&[page], None); + for crop in [CropTo::Bleed, CropTo::Trim] { + let ab = open(&bytes, ImportOptions { crop, ..Default::default() }).artboards[0].rect; + assert_eq!((ab.width(), ab.height()), (280.0, 380.0), "{crop:?}"); + } + let ab = open(&bytes, ImportOptions { crop: CropTo::Art, ..Default::default() }).artboards[0].rect; + assert_eq!((ab.width(), ab.height()), (100.0, 100.0)); + // A page without art keeps its crop box for Bounding. + let ab = open(&bytes, ImportOptions { crop: CropTo::Bounding, ..Default::default() }).artboards[0].rect; + assert_eq!((ab.width(), ab.height()), (280.0, 380.0)); +} + +#[test] +fn a_rotated_page_rotates_its_boxes() { + let page = PdfPage { rotate: 90, ..boxed_page() }; + let d = open(&pdf(&[page], None), ImportOptions { crop: CropTo::Art, ..Default::default() }); + let ab = d.artboards[0].rect; + assert_eq!((ab.width(), ab.height()), (300.0, 200.0)); + // The rectangle turns with the page (clockwise): PDF (x, y) shows at (y − 100, x − 100) on + // the art box's artboard. + assert!(close(art(&d), Rect::new(50.0, 20.0, 150.0, 220.0)), "{:?}", art(&d)); +} + +#[test] +fn pages_pick_which_pages_import_and_in_what_order() { + let pages: Vec = (1..=4).map(|i| PdfPage::new(100.0 * i as f64, 100.0, "0 g 0 0 10 10 re f")).collect(); + let bytes = pdf(&pages, None); + let d = open(&bytes, ImportOptions { pages: Some(vec![1, 2]), ..Default::default() }); + let widths: Vec = d.artboards.iter().map(|a| a.rect.width()).collect(); + assert_eq!(widths, [200.0, 300.0]); + let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_deref().unwrap_or_default()).collect(); + assert_eq!(names, ["Page 2", "Page 3"]); + assert_eq!(d.artboards.iter().map(|a| a.name.as_str()).collect::>(), ["Artboard 1", "Artboard 2"]); + // Artboards sit side by side with the gap between them. + assert_eq!(d.artboards[1].rect.x0, d.artboards[0].rect.x1 + 36.0); + let d = open(&bytes, ImportOptions { pages: Some(vec![3, 0]), max_pages: Some(1), ..Default::default() }); + assert_eq!(d.artboards.len(), 1); + assert_eq!(d.artboards[0].rect.width(), 400.0); + assert_eq!(import_with_report(&bytes, &ImportOptions { pages: Some(vec![4]), ..Default::default() }).unwrap_err(), PdfError::BadPage(5, 4)); + assert_eq!(import_with_report(&bytes, &ImportOptions { pages: Some(vec![]), ..Default::default() }).unwrap_err(), PdfError::NoPages); +} + +#[test] +fn bounding_boxes_of_several_pages_do_not_overlap() { + let page = |x: f64| PdfPage::new(300.0, 300.0, &format!("0 g {x} 10 50 50 re f")); + let d = open(&pdf(&[page(240.0), page(0.0)], None), ImportOptions { crop: CropTo::Bounding, ..Default::default() }); + let (a, b) = (d.artboards[0].rect, d.artboards[1].rect); + assert_eq!((a.width(), b.width()), (50.0, 50.0)); + assert!(b.x0 >= a.x1 + 36.0, "{a:?} {b:?}"); +} + +#[test] +fn an_encrypted_pdf_opens_with_its_password_only() { + let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret")); + assert_eq!(import_with_report(&bytes, &ImportOptions::default()).unwrap_err(), PdfError::NeedsPassword); + let wrong = ImportOptions { password: Some("guess".into()), ..Default::default() }; + assert_eq!(import_with_report(&bytes, &wrong).unwrap_err(), PdfError::WrongPassword); + assert_eq!(info(&bytes, None).unwrap_err(), PdfError::NeedsPassword); + let d = open(&bytes, ImportOptions { password: Some("secret".into()), ..Default::default() }); + let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() }; + assert_eq!(children.len(), 1, "the decrypted page has its rectangle"); + assert!(close(art(&d), Rect::new(10.0, 40.0, 60.0, 90.0)), "{:?}", art(&d)); + // A file that isn't encrypted ignores a password. + let plain = pdf(&[PdfPage::new(200.0, 100.0, "")], None); + assert!(import_with_report(&plain, &ImportOptions { password: Some("x".into()), ..Default::default() }).is_ok()); +} + +#[test] +fn info_lists_pages_with_their_size_rotation_and_boxes() { + let bytes = pdf(&[boxed_page(), PdfPage { rotate: 270, ..PdfPage::new(100.0, 50.0, "") }], None); + let i = info(&bytes, None).unwrap(); + assert_eq!(i.pages.len(), 2); + let p = &i.pages[0]; + assert_eq!((p.width, p.height, p.rotation), (500.0, 700.0, 0)); + let b = |c: CropTo| p.boxes.iter().find(|(k, _)| *k == c).map(|(_, r)| *r).unwrap(); + assert_eq!(b(CropTo::Media), Rect::new(0.0, 0.0, 600.0, 800.0)); + assert_eq!(b(CropTo::Trim), Rect::new(60.0, 60.0, 540.0, 740.0)); + assert!(p.boxes.iter().all(|(k, _)| *k != CropTo::Bounding)); + assert_eq!((i.pages[1].width, i.pages[1].height, i.pages[1].rotation), (50.0, 100.0, 270)); + assert!(matches!(info(b"not a pdf", None), Err(PdfError::Parse(_)))); +} + +#[test] +fn a_page_without_area_gets_the_size_viewers_give_it() { + let bytes = pdf(&[PdfPage { media: [0.0, 0.0, 0.0, 0.0], ..PdfPage::new(0.0, 0.0, "") }], None); + let i = info(&bytes, None).unwrap(); + let (w, h) = (i.pages[0].width, i.pages[0].height); + assert!(w > 0.0 && h > 0.0); + for crop in [CropTo::Crop, CropTo::Media, CropTo::Art] { + let ab = open(&bytes, ImportOptions { crop, ..Default::default() }).artboards[0].rect; + assert_eq!((ab.width(), ab.height()), (w, h), "{crop:?}"); + } +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index e5c15ac2..61b63115 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -9,6 +9,7 @@ //! (`.vectorcraft`, SVG, `document.inspect`). //! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance. //! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance). +//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests. //! //! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`). // Test support only (a dev-dependency of every crate that uses it): a failed setup or assertion @@ -20,6 +21,7 @@ pub mod fixtures; pub mod geom; pub mod invariants; +pub mod pdf; pub mod raster; pub mod strategies; diff --git a/crates/testkit/src/pdf.rs b/crates/testkit/src/pdf.rs new file mode 100644 index 00000000..4c0d8d06 --- /dev/null +++ b/crates/testkit/src/pdf.rs @@ -0,0 +1,189 @@ +//! Hand-written PDF files for import tests: pages with given boxes, rotation, resources and +//! content, optionally encrypted with a user password (the standard security handler, revision 2: +//! 40-bit RC4), which the PDF writer can't produce. + +use std::fmt::Write as _; + +/// One page: its boxes as `[x0, y0, x1, y1]` in PDF user space. +#[derive(Clone, Debug, Default)] +pub struct PdfPage { + pub media: [f64; 4], + pub crop: Option<[f64; 4]>, + pub bleed: Option<[f64; 4]>, + pub trim: Option<[f64; 4]>, + pub art: Option<[f64; 4]>, + pub rotate: i32, + /// The entries of the page's resource dictionary (`/ColorSpace << … >>`). + pub resources: String, + /// The content stream (operators, uncompressed). + pub content: String, +} + +impl PdfPage { + /// A `w` × `h` pt page drawing `content`. + pub fn new(w: f64, h: f64, content: &str) -> Self { + Self { media: [0.0, 0.0, w, h], content: content.into(), ..Default::default() } + } +} + +/// Padding of passwords (PDF 1.7, 7.6.3.3). +const PAD: [u8; 32] = [ + 0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, + 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A, +]; + +fn padded(pw: &str) -> Vec { + let mut v: Vec = pw.bytes().take(32).collect(); + v.extend_from_slice(&PAD[..32 - v.len()]); + v +} + +fn hex(b: &[u8]) -> String { + b.iter().fold(String::new(), |mut s, x| { + let _ = write!(s, "{x:02X}"); + s + }) +} + +fn rect(r: [f64; 4]) -> String { + format!("[{} {} {} {}]", r[0], r[1], r[2], r[3]) +} + +/// A PDF of `pages`; with a `password` it is encrypted and opens only with it (its owner password +/// is the same followed by `-owner`). +pub fn pdf(pages: &[PdfPage], password: Option<&str>) -> Vec { + let id = md5(b"vectorcraft test file"); + // (O, U, file key) of the standard security handler, revision 2, permissions -4. + let crypt = password.map(|pw| { + let owner_key = md5(&padded(&format!("{pw}-owner"))); + let o = rc4(&owner_key[..5], &padded(pw)); + let mut k = padded(pw); + k.extend_from_slice(&o); + k.extend_from_slice(&(-4i32).to_le_bytes()); + k.extend_from_slice(&id); + let key = md5(&k)[..5].to_vec(); + let u = rc4(&key, &PAD); + (o, u, key) + }); + let mut objects: Vec> = vec![]; + let kids: Vec = (0..pages.len()).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objects.push(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()); + objects.push(format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), pages.len()).into_bytes()); + for (i, p) in pages.iter().enumerate() { + let mut d = format!("<< /Type /Page /Parent 2 0 R /MediaBox {} /Contents {} 0 R /Resources << {} >>", rect(p.media), 4 + 2 * i, p.resources); + for (k, r) in [("CropBox", p.crop), ("BleedBox", p.bleed), ("TrimBox", p.trim), ("ArtBox", p.art)] { + if let Some(r) = r { + let _ = write!(d, " /{k} {}", rect(r)); + } + } + if p.rotate != 0 { + let _ = write!(d, " /Rotate {}", p.rotate); + } + d.push_str(" >>"); + objects.push(d.into_bytes()); + let num = objects.len() + 1; + let data = match &crypt { + Some((_, _, key)) => { + let mut k = key.clone(); + k.extend_from_slice(&(num as u32).to_le_bytes()[..3]); + k.extend_from_slice(&[0, 0]); + rc4(&md5(&k)[..10], p.content.as_bytes()) + } + None => p.content.as_bytes().to_vec(), + }; + let mut s = format!("<< /Length {} >>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(&data); + s.extend_from_slice(b"\nendstream"); + objects.push(s); + } + if let Some((o, u, _)) = &crypt { + objects.push(format!("<< /Filter /Standard /V 1 /R 2 /O <{}> /U <{}> /P -4 >>", hex(o), hex(u)).into_bytes()); + } + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offsets = vec![]; + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n", i + 1).bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes()); + for off in offsets { + out.extend(format!("{off:010} 00000 n \n").bytes()); + } + let encrypt = if crypt.is_some() { format!(" /Encrypt {} 0 R", objects.len()) } else { String::new() }; + let (size, id) = (objects.len() + 1, hex(&id)); + out.extend(format!("trailer\n<< /Size {size} /Root 1 0 R /ID [<{id}> <{id}>]{encrypt} >>\nstartxref\n{xref}\n%%EOF\n").bytes()); + out +} + +/// RC4 of `data` with `key`. +fn rc4(key: &[u8], data: &[u8]) -> Vec { + let mut s: Vec = (0..=255).collect(); + let mut j = 0u8; + for i in 0..256 { + j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]); + s.swap(i, j as usize); + } + let (mut i, mut j) = (0u8, 0u8); + data.iter() + .map(|b| { + i = i.wrapping_add(1); + j = j.wrapping_add(s[i as usize]); + s.swap(i as usize, j as usize); + b ^ s[s[i as usize].wrapping_add(s[j as usize]) as usize] + }) + .collect() +} + +/// MD5 of `data` (RFC 1321). +fn md5(data: &[u8]) -> [u8; 16] { + const SHIFT: [u32; 16] = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]; + let k: Vec = (0..64).map(|i| ((i as f64 + 1.0).sin().abs() * 4_294_967_296.0) as u32).collect(); + let mut h = [0x6745_2301u32, 0xefcd_ab89, 0x98ba_dcfe, 0x1032_5476]; + let mut msg = data.to_vec(); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&((data.len() as u64).wrapping_mul(8)).to_le_bytes()); + for chunk in msg.chunks(64) { + let m: Vec = chunk.chunks(4).map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])).collect(); + let [mut a, mut b, mut c, mut d] = h; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(SHIFT[(i / 16) * 4 + i % 4])); + } + for (x, y) in h.iter_mut().zip([a, b, c, d]) { + *x = x.wrapping_add(y); + } + } + let mut out = [0u8; 16]; + for (o, w) in out.chunks_mut(4).zip(h) { + o.copy_from_slice(&w.to_le_bytes()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn md5_and_rc4_match_their_reference_vectors() { + assert_eq!(hex(&md5(b"")), "D41D8CD98F00B204E9800998ECF8427E"); + assert_eq!(hex(&md5(b"abc")), "900150983CD24FB0D6963F7D28E17F72"); + assert_eq!(hex(&md5(&[b'a'; 100])), "36A92CC94A9E0FA21F625F8BFB007ADF"); + assert_eq!(hex(&rc4(b"Key", b"Plaintext")), "BBF316E8D940AF0AD3"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 3c09fd74..96cd7e92 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -26,3 +26,6 @@ image = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } + +[dev-dependencies] +vectorcraft-testkit = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 02104954..b66fc583 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -145,10 +145,7 @@ fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Op } let doc = st.doc.clone(); let r = doc.artboards.get(i)?.rect; - let scale = 92.0 / r.width().max(r.height()).max(1.0); - let img = app.canvas.renderer.render_region(&doc, r, scale, true); - let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR); + let tex = crate::widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("ab-thumb-{i}"), &doc, r, 92.0); ctx.data_mut(|d| d.insert_temp(key, tex.clone())); Some(tex) } diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs new file mode 100644 index 00000000..acb40410 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -0,0 +1,276 @@ +//! The Import PDF dialog: opening or placing a multi-page or password-protected PDF (or `.ai`) +//! 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 +//! 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`]. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_pdf::{Choice, CropTo, PdfError}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +pub(super) const KIND: &str = "importPdf"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |d| if d.str("mode") == "place" { "Place PDF" } else { "Import PDF" }.into(), body, confirm, ..DialogSpec::FORM }; + +/// The longest side of the page preview (pt). +const PREVIEW: f32 = 200.0; +const LABEL_WIDTH: f32 = 70.0; + +/// The file the dialog reads. +#[derive(Debug)] +pub struct DialogFile { + pub bytes: Vec, + /// Unique per file the dialog opened (keys its preview cache). + token: u64, +} + +/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the +/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to +/// ask (the caller goes on). +pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, place: Option<&Value>) -> bool { + if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) { + return false; + } + let (pages, locked) = match vectorcraft_pdf::info(bytes, None) { + Ok(i) if i.pages.len() > 1 => (i.pages.len(), false), + Err(PdfError::NeedsPassword) => (0, true), + _ => return false, + }; + static TOKEN: AtomicU64 = AtomicU64::new(0); + app.ui.dialog_file = Some(Arc::new(DialogFile { bytes: bytes.to_vec(), token: TOKEN.fetch_add(1, Ordering::Relaxed) })); + let mut fields = json!({ + "mode": if place.is_some() { "place" } else { "open" }, + "name": name, + "pageCount": pages, + "page": 1, + "allPages": true, + "range": "", + "cropTo": CropTo::default().id(), + "password": "", + "__locked": locked, + }); + if let Some(p) = path { + fields["path"] = json!(p); + } + if let Some(Value::Object(p)) = place { + let rest: serde_json::Map = p + .iter() + .filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64" | "page" | "crop" | "password")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + fields["__place"] = Value::Object(rest); + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); + true +} + +/// `file.place` with params `p` asks first (true) when they name a PDF (`.pdf`, `.ai`, `.ait`) +/// with several pages or a password and no `page`. +pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool { + if p.get("page").is_some() { + return false; + } + let s = |k: &str| p.get(k).and_then(Value::as_str); + let (name, bytes, path) = match (s("path"), s("dataBase64")) { + (Some(path), _) if matches!(fileio::extension(path).as_str(), "pdf" | "ai" | "ait") => { + let Some(bytes) = app.services.read.as_ref().and_then(|r| r(path).ok()) else { return false }; + (path.to_string(), bytes, Some(path.to_string())) + } + (None, Some(b64)) => match vectorcraft_format::base64_decode(b64) { + Some(bytes) => (s("name").unwrap_or("Untitled").to_string(), bytes, None), + None => return false, + }, + _ => return false, + }; + offer(app, &name, &bytes, path, Some(p)) +} + +fn page_count(d: &Dialog) -> usize { + d.fields.get("pageCount").and_then(Value::as_u64).unwrap_or(0) as usize +} + +/// The page shown (1-based, within the file). +fn page(d: &Dialog) -> usize { + (d.f64("page", 1.0).max(1.0) as usize).min(page_count(d).max(1)) +} + +fn crop(d: &Dialog) -> CropTo { + CropTo::IDS.iter().position(|id| *id == d.str("cropTo")).and_then(|i| CropTo::ALL.get(i).copied()).unwrap_or_default() +} + +fn password(d: &Dialog) -> Option { + Some(d.str("password")).filter(|p| !p.is_empty()) +} + +/// 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")?; + let mut d = d.clone(); + if d.bool("__locked") { + let info = vectorcraft_pdf::info(&file.bytes, password(&d).as_deref()).map_err(|e| e.to_string())?; + d.fields.insert("__locked".into(), json!(false)); + d.fields.insert("pageCount".into(), json!(info.pages.len())); + if info.pages.len() > 1 { + // Unlocked: now pick the pages. + app.ui.dialog = Some(d); + return Ok(Value::Null); + } + } + let name = d.str("name"); + let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); + let r = if d.str("mode") == "place" { + let mut p = d.fields.get("__place").cloned().filter(Value::is_object).unwrap_or_else(|| json!({})); + match &path { + Some(path) => p["path"] = json!(path), + None => { + p["name"] = json!(name); + p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes)); + } + } + p["page"] = json!(page(&d)); + p["crop"] = json!(crop(&d).id()); + if let Some(pw) = password(&d) { + p["password"] = json!(pw); + } + crate::place::run(app, &p).map(|_| ()) + } else { + let mut p = json!({"cropTo": crop(&d).id(), "password": password(&d)}); + let range = d.str("range"); + if !d.bool("allPages") && !range.trim().is_empty() { + p["pages"] = json!(range); + } + io::open_document(app, &name, &file.bytes, path, &p) + }; + match r { + Ok(()) => { + app.ui.dialog = None; + Ok(Value::Null) + } + Err(e) => { + // Keep what was typed (the dialog stays open on errors such as a bad range). + app.ui.dialog = Some(d); + Err(e) + } + } +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + if d.bool("__locked") { + let name = d.str("name"); + let file = std::path::Path::new(&name).file_name().map_or(name.clone(), |f| f.to_string_lossy().into_owned()); + ui.label(egui::RichText::new(format!("“{file}” is protected by a password.")).color(t.text)); + ui.add_space(8.0); + password_row(ui, d); + return false; + } + let count = page_count(d); + let mut current = page(d); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover()); + ui.painter().rect_filled(r, 2.0, t.pasteboard); + if let Some(tex) = preview(app, ui.ctx(), d, current) { + let sz = tex.size_vec2(); + let ir = egui::Rect::from_center_size(r.center(), sz * (PREVIEW / sz.x.max(sz.y))); + ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside); + } + ui.add_space(6.0); + // Page navigation: first, previous, the page number, next, last. + ui.horizontal(|ui| { + let nav = |ui: &mut egui::Ui, icon: &str, tip: &str, to: usize, enabled: bool| { + widgets::icon_button_enabled(ui, icon, tip, false, enabled, 22.0).clicked().then_some(to) + }; + let go = [ + nav(ui, "chevrons-left", "First Page", 1, current > 1), + nav(ui, "chevron-left", "Previous Page", current.saturating_sub(1).max(1), current > 1), + ]; + if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) { + current = (v.max(1.0) as usize).min(count.max(1)); + } + ui.label(egui::RichText::new(format!("of {count}")).color(t.text_dim)); + let go2 = [ + nav(ui, "chevron-right", "Next Page", (current + 1).min(count), current < count), + nav(ui, "chevrons-right", "Last Page", count, current < count), + ]; + if let Some(to) = go.into_iter().chain(go2).flatten().next() { + current = to; + } + }); + }); + ui.add_space(14.0); + ui.vertical(|ui| { + widgets::label_row(ui, "Crop To", LABEL_WIDTH, |ui| { + if let Some(i) = widgets::dropdown(ui, "pdf-crop", crop(d).label(), CropTo::LABELS, 130.0) + && let Some(id) = CropTo::IDS.get(i) + { + d.fields.insert("cropTo".into(), json!(id)); + } + }); + if d.str("mode") != "place" { + ui.add_space(10.0); + ui.label(egui::RichText::new("Pages").color(t.text)); + let all = d.bool("allPages"); + if widgets::radio(ui, "All", all, true) { + d.fields.insert("allPages".into(), json!(true)); + } + ui.horizontal(|ui| { + if widgets::radio(ui, "Range:", !all, true) { + d.fields.insert("allPages".into(), json!(false)); + } + if super::form::text_edit(ui, d, "range", 90.0).gained_focus() { + d.fields.insert("allPages".into(), json!(false)); + } + }); + widgets::dim_label(ui, "e.g. 1-3, 5"); + } + }); + }); + d.fields.insert("page".into(), json!(current)); + false +} + +fn password_row(ui: &mut egui::Ui, d: &mut Dialog) { + widgets::label_row(ui, "Password", LABEL_WIDTH, |ui| { + let mut s = d.str("password"); + let r = ui.add(egui::TextEdit::singleline(&mut s).password(true).desired_width(180.0)); + if r.changed() { + d.fields.insert("password".into(), json!(s)); + } + if ui.memory(|m| m.focused().is_none()) { + r.request_focus(); + } + }); +} + +/// Page `page` (1-based) cropped as the dialog says, rendered once per file, page and box. +fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usize) -> Option { + let file = app.ui.dialog_file.clone()?; + let crop = crop(d); + let key = egui::Id::new(("pdf-preview", file.token, page, crop.id())); + if let Some(t) = ctx.data(|m| m.get_temp::>(key)) { + return t; + } + let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() }; + // A page that can't be read shows an empty preview (and isn't read again). + let tex = fileio::page_document(&file.bytes, page - 1, &opts).ok().and_then(|(doc, _)| { + let r = doc.artboards.first()?.rect; + let px = PREVIEW as f64 * ctx.pixels_per_point() as f64; + Some(widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("pdf-preview-{page}"), &doc, r, px)) + }); + ctx.data_mut(|m| m.insert_temp(key, tex.clone())); + tex +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 17197412..4621a528 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -25,6 +25,7 @@ pub mod flattener_presets; mod form; mod gradient_stop; pub mod graphic_style_options; +pub mod import_pdf; pub mod new_color_group; mod new_document; pub mod new_swatch; @@ -186,6 +187,7 @@ registry! { Place: [place::KIND] => place::SPEC, RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC, ExportAs: ["exportAs"] => export_as::SPEC, + ImportPdf: [import_pdf::KIND] => import_pdf::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -216,7 +218,10 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { about::show(app, ctx); - let Some(mut d) = app.ui.dialog.clone() else { return }; + let Some(mut d) = app.ui.dialog.clone() else { + app.ui.dialog_file = None; + return; + }; let spec = spec(&d.kind); if let Some(window) = spec.window { return window(app, ctx); @@ -286,3 +291,5 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { mod tests; #[cfg(test)] mod tests_export; +#[cfg(test)] +mod tests_import_pdf; diff --git a/crates/ui-egui/src/dialogs/tests_import_pdf.rs b/crates/ui-egui/src/dialogs/tests_import_pdf.rs new file mode 100644 index 00000000..cc645524 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_import_pdf.rs @@ -0,0 +1,166 @@ +//! The Import PDF dialog: opening and placing multi-page and password-protected PDFs. + +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_engine::Session; +use vectorcraft_testkit::pdf::{PdfPage, pdf}; + +use super::*; +use crate::io; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let mut out = ctx.run_ui(Default::default(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v); +} + +fn field(app: &VectorcraftApp, key: &str) -> serde_json::Value { + app.ui.dialog.as_ref().unwrap().fields[key].clone() +} + +/// Three pages, 100, 200 and 300 pt wide; the second has a 50 × 60 art box. +fn three_pages() -> Vec { + let mut second = PdfPage::new(200.0, 200.0, "0 0 1 rg 20 20 40 40 re f"); + second.art = Some([10.0, 10.0, 60.0, 70.0]); + pdf(&[PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), second, PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")], None) +} + +fn artboard_widths(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect() +} + +#[test] +fn opening_a_multi_page_pdf_asks_for_the_pages() { + let mut app = app(); + io::open_bytes(&mut app, "three.pdf", &three_pages(), Some("/tmp/three.pdf".into())).unwrap(); + assert_eq!(app.session.documents().len(), 1, "nothing opened yet"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("mode"), field(&app, "pageCount")), (import_pdf::KIND, "open".into(), json!(3))); + assert_eq!(field(&app, "cropTo"), "crop"); + frame(&mut app); + // Page navigation stays within the file. + set(&mut app, "page", json!(9)); + frame(&mut app); + assert_eq!(field(&app, "page"), 3); + // A bad range keeps the dialog open with the message. + set(&mut app, "allPages", json!(false)); + set(&mut app, "range", json!("2-7")); + assert!(confirm(&mut app).unwrap_err().contains("1 to 3")); + assert!(app.ui.dialog.is_some()); + set(&mut app, "range", json!("2-3")); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(artboard_widths(&app), [200.0, 300.0]); + assert_eq!(app.views.len(), 2, "views follow the documents"); + // The file is let go once no dialog is open. + assert!(app.ui.dialog_file.is_some()); + frame(&mut app); + assert!(app.ui.dialog_file.is_none()); +} + +#[test] +fn all_pages_and_a_crop_box() { + let mut app = app(); + io::open_bytes(&mut app, "three.pdf", &three_pages(), None).unwrap(); + set(&mut app, "cropTo", json!("bounding")); + confirm(&mut app).unwrap(); + assert_eq!(artboard_widths(&app), [10.0, 40.0, 30.0]); +} + +#[test] +fn single_page_pdfs_open_directly() { + let mut app = app(); + io::open_bytes(&mut app, "one.pdf", &pdf(&[PdfPage::new(80.0, 40.0, "")], None), None).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(artboard_widths(&app), [80.0]); +} + +#[test] +fn a_password_protected_pdf_asks_for_its_password() { + let mut app = app(); + let bytes = pdf(&[PdfPage::new(120.0, 60.0, "1 0 0 rg 0 0 20 20 re f")], Some("pw")); + io::open_bytes(&mut app, "locked.pdf", &bytes, None).unwrap(); + assert_eq!(field(&app, "__locked"), true); + frame(&mut app); + set(&mut app, "password", json!("nope")); + assert!(confirm(&mut app).unwrap_err().contains("wrong")); + assert_eq!(field(&app, "__locked"), true, "still locked"); + set(&mut app, "password", json!("pw")); + // One page: unlocking opens it. + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(artboard_widths(&app), [120.0]); + // Several pages: unlocking shows the pages first. + let two = pdf(&[PdfPage::new(10.0, 10.0, ""), PdfPage::new(20.0, 10.0, "")], Some("pw")); + io::open_bytes(&mut app, "two.pdf", &two, None).unwrap(); + set(&mut app, "password", json!("pw")); + confirm(&mut app).unwrap(); + assert_eq!((field(&app, "__locked"), field(&app, "pageCount")), (json!(false), json!(2))); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(artboard_widths(&app), [10.0, 20.0]); +} + +/// The placed object: its kind (clip group or not) and bounds. +fn placed(app: &VectorcraftApp) -> (bool, vectorcraft_geom::Rect) { + let st = app.session.active().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap(); + let NodeKind::Group { clip, .. } = n.kind else { panic!("{:?}", n.kind) }; + (clip, n.geometric_bounds().unwrap()) +} + +fn place(app: &mut VectorcraftApp, bytes: &[u8], extra: serde_json::Value) -> Result { + let mut p = json!({"name": "three.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)}); + if let (Some(o), serde_json::Value::Object(e)) = (p.as_object_mut(), extra) { + o.extend(e); + } + crate::place::run(app, &p) +} + +#[test] +fn placing_a_pdf_page_asks_for_the_page_and_crop_box() { + let mut app = app(); + let bytes = three_pages(); + place(&mut app, &bytes, json!({"at": [100, 120], "link": false})).unwrap(); + assert_eq!((field(&app, "mode"), field(&app, "cropTo")), (json!("place"), json!("crop"))); + assert_eq!(field(&app, "__place"), json!({"at": [100, 120], "link": false}), "the other place params wait in the dialog"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| (spec(&d.kind).heading)(d) == "Place PDF")); + frame(&mut app); + set(&mut app, "page", json!(2)); + set(&mut app, "cropTo", json!("art")); + let clipboard = app.session.clipboard.len(); + let undo = app.session.doc().unwrap().history.undo.len(); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let (clip, b) = placed(&app); + assert!(clip, "clipped to the art box"); + assert_eq!((b.width(), b.height()), (50.0, 60.0)); + assert_eq!(b.center(), vectorcraft_geom::Point::new(100.0, 120.0), "where `at` asked"); + assert_eq!(app.session.clipboard.len(), clipboard, "the clipboard is untouched"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // With a page, no dialog; Bounding Box places the art itself. + place(&mut app, &bytes, json!({"page": 3, "crop": "bounding"})).unwrap(); + assert!(app.ui.dialog.is_none()); + let (clip, b) = placed(&app); + assert!(!clip); + assert_eq!((b.width(), b.height()), (30.0, 30.0)); + assert!(place(&mut app, &bytes, json!({"page": 4})).is_err()); + // A one-page PDF places right away. + place(&mut app, &pdf(&[PdfPage::new(80.0, 40.0, "0 g 0 0 10 10 re f")], None), json!({})).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(placed(&app).1.width(), 80.0); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index f02e5d31..adb61136 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -26,7 +26,16 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; return load(app, p).map(|_| ()); } - let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?; + // A PDF with several pages or a password asks first (the Import PDF dialog). + if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) { + return Ok(()); + } + open_document(app, name, bytes, path, &Value::Null) +} + +/// Open a document through the engine loader with the `document.open` options in `p`. +pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result<(), String> { + let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?; app.sync_views(); if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default())); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26d66d7e..c996b210 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -62,7 +62,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.place", "Place…", "Cmd+Shift+P", - "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view", + "{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)", ), ("file.openRecent1", "Open Recent File 1", "", "{}"), ("file.openRecent2", "Open Recent File 2", "", "{}"), diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 24910d8f..40815f9d 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -81,6 +81,10 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { open_dialog(app, files); return Ok(Value::Null); } + // A PDF with several pages or a password asks for the page, box and password first. + if crate::dialogs::import_pdf::offer_place(app, p) { + return Ok(Value::Null); + } let mut p = p.clone(); if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none()) && let Some(v) = app.view() diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ea6abe14..e3884caf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -266,6 +266,10 @@ pub struct UiState { /// File → Place: Link is on (the Place dialog remembers it). #[serde(default = "yes")] pub place_link: bool, + /// The file the open dialog reads, kept out of its JSON fields (Import PDF); dropped when no + /// dialog is open. + #[serde(skip)] + pub dialog_file: Option>, } impl UiState { @@ -323,6 +327,7 @@ impl Default for UiState { color_guide_limit: String::new(), svg_options: Value::Null, place_link: true, + dialog_file: None, } } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 54532a4f..b41ab9f4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1247,3 +1247,19 @@ pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &st ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); resp.on_hover_text(tip).clicked() } + +/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name` +/// (artboard and page thumbnails). +pub fn region_texture( + ctx: &egui::Context, + renderer: &mut vectorcraft_render::Renderer, + name: &str, + doc: &vectorcraft_doc::Document, + region: vectorcraft_geom::Rect, + px: f64, +) -> egui::TextureHandle { + let scale = px / region.width().max(region.height()).max(1.0); + let img = renderer.render_region(doc, region, scale, true); + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + ctx.load_texture(name, color, egui::TextureOptions::LINEAR) +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 41b16380..5ae932b4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,6 +26,8 @@ | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | +| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | + Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch (fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, `preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo diff --git a/docs/mcp.md b/docs/mcp.md index 050844ee..08ccdf32 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -185,6 +185,16 @@ documents every field). `document.exportPdf` and `export` (format `pdf`, the opt options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused. `document.pdfSettings` lists the options that differ from the defaults and the warnings without writing a file. +Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), +`cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets) +and `password` for an encrypted file. `document.pdfInfo` reads a file without opening it: the page count, each page's +size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n: + +```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"}}} +``` + Drive a tool like a mouse: ```json From 8765d335ec0f6fe3983c51a43a360825d4301184 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:49:34 +0700 Subject: [PATCH 2/3] M4.46: PDF import keeps CMYK, Gray and spot colours DeviceCMYK and CMYK ICC colours import as CMYK and a file painted mostly in CMYK opens as a CMYK document (with CMYK default swatches); DeviceGray imports as Gray; Separation and DeviceN inks become global spot swatches (their 100 % colour from the alternate space: CMYK, RGB, Gray or Lab) that the art links to at its tint, /All links to Registration and process colorants make CMYK. Shading stops keep their model and ink links the same way. Inks mixed in one DeviceN colour, or spaces that can't be told apart, import as RGB (with a warning for the mix). document.open takes colorMode (rgb | cmyk) to open any file in that mode, converted as file.documentColorMode does (its conversion is now shared). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/docmenu.rs | 32 +- crates/engine/src/cmd/fileio/load.rs | 3 + crates/engine/src/cmd/fileio/mod.rs | 4 +- crates/engine/src/cmd/fileio/pdfimport.rs | 18 +- .../engine/src/cmd/fileio/tests_pdfcolor.rs | 64 +++ .../engine/src/cmd/fileio/tests_pdfimport.rs | 2 +- crates/engine/tests/import_fuzz.rs | 8 + crates/pdf/src/import.rs | 54 ++- crates/pdf/src/import_color.rs | 419 ++++++++++++++++++ crates/pdf/src/lib.rs | 7 +- crates/pdf/src/tests_import_color.rs | 217 +++++++++ crates/testkit/src/pdf.rs | 12 + docs/mcp.md | 5 +- 13 files changed, 807 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_pdfcolor.rs create mode 100644 crates/pdf/src/import_color.rs create mode 100644 crates/pdf/src/tests_import_color.rs diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 19ed7014..49614fc5 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -456,6 +456,19 @@ fn color_mode(s: &mut Session, p: &Value) -> Result { if s.doc()?.doc.color_mode == mode { return ok(); } + s.edit("Document Color Mode", |d, _| { + set_color_mode(d, mode, convert); + Ok(()) + })?; + ok() +} + +/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too. +pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) { + d.color_mode = mode; + if !convert { + return; + } let f = move |c: vectorcraft_color::Color| match mode { ColorMode::Cmyk => to_cmyk(c), ColorMode::Rgb => { @@ -463,20 +476,13 @@ fn color_mode(s: &mut Session, p: &Value) -> Result { vectorcraft_color::Color::rgb(r, g, b) } }; - s.edit("Document Color Mode", |d, _| { - d.color_mode = mode; - if convert { - let layers: Vec = d.layers.iter().map(|l| l.id).collect(); - Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers); - for sw in d.swatches_iter_mut() { - if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { - *color = f(*color); - } - } + let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers); + for sw in d.swatches_iter_mut() { + if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { + *color = f(*color); } - Ok(()) - })?; - ok() + } } fn file_info(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 24acb1b5..f993bb4a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -115,6 +115,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result _ if format.raster => raster_doc(&title, bytes)?, _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; + if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { + super::super::docmenu::set_color_mode(&mut doc, mode, true); + } // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if format.id != "vectorcraft" || doc.title.is_empty() { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 6a39d564..dc8d490c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; 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) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document", always, load::open ), @@ -468,6 +468,8 @@ mod tests; #[cfg(test)] mod tests_pdf; #[cfg(test)] +mod tests_pdfcolor; +#[cfg(test)] mod tests_pdfimport; #[cfg(test)] mod tests_svg; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 9bec318e..3023bd87 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -3,13 +3,14 @@ //! boxes, a thumbnail) for the Import PDF dialog. use serde_json::{Value, json}; -use vectorcraft_doc::Document; +use vectorcraft_doc::{ColorMode, Document}; use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError}; use super::super::*; use super::{err, source}; -/// The `document.open` options besides the file. PDF only for now; other formats ignore them. +/// The `document.open` options besides the file. The PDF ones (pages, box, password) are ignored +/// by other formats. #[derive(Clone, Debug, Default, PartialEq)] pub struct LoadOptions { /// 1-based pages to import, such as `"2-3, 5"`; `None` = every page. @@ -18,11 +19,14 @@ pub struct LoadOptions { pub crop: CropTo, /// The password of an encrypted PDF. pub password: Option, + /// The colour mode the document opens in, its colours converted as Document Color Mode does + /// (default: the file's; a PDF painted mostly in CMYK opens in CMYK). + pub color_mode: Option, } impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when - /// `pages` isn't given), `cropTo` (or `crop`) and `password`. + /// `pages` isn't given), `cropTo` (or `crop`), `password` and `colorMode` (`rgb` | `cmyk`). 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, @@ -41,7 +45,13 @@ impl LoadOptions { .ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?, }; let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string); - Ok(Self { pages, crop, password }) + let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() { + None => None, + Some("rgb") => Some(ColorMode::Rgb), + Some("cmyk") => Some(ColorMode::Cmyk), + Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), + }; + Ok(Self { pages, crop, password, color_mode }) } /// The PDF import options: `pages` resolved against the file's page count. diff --git a/crates/engine/src/cmd/fileio/tests_pdfcolor.rs b/crates/engine/src/cmd/fileio/tests_pdfcolor.rs new file mode 100644 index 00000000..b5b3c651 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdfcolor.rs @@ -0,0 +1,64 @@ +//! `document.open` keeps a PDF's CMYK, Gray and spot colours, and `colorMode` picks the mode. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::ColorMode; + +use super::*; + +/// A CMYK document with a CMYK fill, a Gray fill and a 40 % tint of spot ink "Gold", as PDF. +fn cmyk_pdf() -> String { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50, "colorMode": "cmyk"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Gold", "color": {"model": "cmyk", "c": 0.0, "m": 0.2, "y": 0.8, "k": 0.1}, "spot": true})).unwrap(); + for (x, fill) in [ + (0, json!({"color": {"model": "cmyk", "c": 0.6, "m": 0.1, "y": 0.0, "k": 0.2}})), + (20, json!({"color": {"model": "gray", "k": 0.3}})), + (40, json!({"swatch": "Gold", "tint": 40})), + ] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + } + let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); + v["dataBase64"].as_str().unwrap().to_string() +} + +fn fills(s: &Session) -> Vec { + let d = &s.active().unwrap().doc; + d.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect() +} + +#[test] +fn a_cmyk_pdf_opens_as_a_cmyk_document_with_its_spot_swatch() { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf()})).unwrap(); + assert_eq!(s.execute("file.info", &json!({})).unwrap()["colorMode"], "cmyk"); + let f = fills(&s); + assert!(matches!(f[0].color(), Some(Color::Cmyk { c, .. }) if (c - 0.6).abs() < 0.004), "{:?}", f[0]); + assert!(matches!(f[1].color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 0.004), "{:?}", f[1]); + assert!(matches!(&f[2], Paint::Solid { swatch: Some(n), tint, .. } if n == "Gold" && (tint - 0.4).abs() < 0.004), "{:?}", f[2]); + let d = &s.active().unwrap().doc; + let gold = d.swatch("Gold").unwrap(); + assert!(gold.spot && gold.global); + assert!(matches!(gold.paint.color(), Some(Color::Cmyk { m, y, .. }) if (m - 0.2).abs() < 0.004 && (y - 0.8).abs() < 0.004)); +} + +#[test] +fn color_mode_opens_the_file_in_the_mode_asked_for() { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "RGB"})).unwrap(); + assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Rgb); + let f = fills(&s); + assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. }))), "converted as Document Color Mode does: {f:?}"); + // Any format opens in the mode asked for. + let mut svg = Session::new(); + svg.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + svg.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let text = svg.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("document.open", &json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes()), "colorMode": "cmyk"})) + .unwrap(); + assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Cmyk); + assert!(fills(&s).iter().all(|p| !matches!(p.color(), Some(Color::Rgb { .. })))); + let e = s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "lab"})).unwrap_err().to_string(); + assert!(e.contains("rgb or cmyk"), "{e}"); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs index 4d10b31e..c8853c27 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -140,7 +140,7 @@ fn file_place_crops_a_pdf_page_to_any_box_and_takes_its_password() { #[test] fn load_options_read_pages_crop_and_password() { let o = LoadOptions::from_params("x", &json!({"page": 3, "crop": "trim", "password": "p"})).unwrap(); - assert_eq!(o, LoadOptions { pages: Some("3".into()), crop: vectorcraft_pdf::CropTo::Trim, password: Some("p".into()) }); + assert_eq!(o, LoadOptions { pages: Some("3".into()), crop: vectorcraft_pdf::CropTo::Trim, password: Some("p".into()), ..Default::default() }); // `pages` wins over `page`; an empty password is none. let o = LoadOptions::from_params("x", &json!({"pages": "1-2", "page": 3, "password": ""})).unwrap(); assert_eq!((o.pages.as_deref(), o.password), (Some("1-2"), None)); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4ba505bb..7096923b 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -273,6 +273,8 @@ fn arb_pdf_op() -> impl Strategy { Just("/Im0".to_string()), Just("/GS0".to_string()), Just("/DeviceRGB".to_string()), + Just("/CS0".to_string()), + Just("/DeviceCMYK".to_string()), Just("/Pattern".to_string()), Just("(Hi)".to_string()), Just("<00ff>".to_string()), @@ -290,6 +292,12 @@ fn arb_resources() -> impl Strategy { "<< /Shading << /Sh0 << /ShadingType 3 /ColorSpace /DeviceRGB /Coords [0 0 0 0 0 -5] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [] /N -1 >> >> >> >>".to_string(), "<< /XObject << /Im0 << /Type /XObject /Subtype /Image /Width 4294967295 /Height 0 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >> >> >>".to_string(), "<< /Pattern << /P0 << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0 0] /XStep 0 /YStep -0 /Resources << >> >> >> >>".to_string(), + // Ink colour spaces with junk names, alternates and tint transforms (spot swatch import). + "<< /ColorSpace << /CS0 [/Separation /All /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [1 1 1 1] /N 1 >>] >> >>".to_string(), + "<< /ColorSpace << /CS0 [/Separation /#00 /Lab << /FunctionType 2 /Domain [0 1] /C0 [1e308] /C1 [NaN 5] /N -1 >>] >> >>".to_string(), + "<< /ColorSpace << /CS0 [/DeviceN [] /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(), + "<< /ColorSpace << /CS0 [/DeviceN [/A /None /Cyan /All] [/ICCBased << /N 4 >>] << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(), + "<< /Shading << /Sh0 << /ShadingType 2 /ColorSpace [/Separation /Ink /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> >>".to_string(), ]) } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 88f0198d..fcd5947d 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -14,11 +14,12 @@ use hayro_syntax::object::Name; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, - StrokeLayer, + Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, + NodeKind, StrokeLayer, }; use vectorcraft_geom::{FillRule, PathData}; +use crate::import_color::{Colors, Native}; use crate::{CropTo, ImportOptions, ImportReport, PdfError}; /// Import a PDF (or PDF-compatible `.ai`) with default options. @@ -54,7 +55,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result { + colors: Colors<'p>, next: u64, stack: Vec, blend: BlendMode, @@ -203,9 +215,10 @@ fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } -impl Builder { - fn new(next: u64) -> Self { +impl<'p> Builder<'p> { + fn new(next: u64, colors: Colors<'p>) -> Self { Self { + colors, next, stack: vec![], blend: BlendMode::Normal, @@ -331,10 +344,11 @@ impl Builder { match p { hayro_interpret::Paint::Color(c) => { let [r, g, b, a] = c.to_rgba().components(); - (Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), a) + let paint = self.colors.solid(c).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint); + (paint, a) } hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() { - Pattern::Shading(sp) => match shading_gradient(sp) { + Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) { Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity), None => { self.warn("mesh/function shadings are imported as a flat colour"); @@ -368,17 +382,19 @@ impl Builder { } } -/// Axial/radial shading → gradient paint (stops sampled from the shading function). -fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option { +/// Axial/radial shading → gradient paint (stops sampled from the shading function, in the +/// shading's own colour model). +fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern, colors: &mut Colors<'_>) -> Option { let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else { return None; }; let m = sp.matrix; let cs = &sp.shading.color_space; + let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]); + let space = colors.shading_space(cs, at(0.0)?.len()); + // RGB to decide which samples are stops; the stops keep the shading's model. let sample = |t: f32| -> Option<(Color, f32)> { - let x = domain[0] + (domain[1] - domain[0]) * t; - let v = function.eval(&smallvec::smallvec![x])?; - let [r, g, b, a] = cs.to_rgba(&v, 1.0, false).components(); + let [r, g, b, a] = cs.to_rgba(&at(t)?, 1.0, false).components(); Some((Color::rgb(round3(r), round3(g), round3(b)), a)) }; // Sample densely, then drop samples that linear interpolation reproduces. @@ -400,9 +416,13 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option GradientStop { - let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0)); - GradientStop::new(offset, color) + let mut stop_at = |t: f32, offset: f32| -> GradientStop { + let (rgb, _) = sample(t).unwrap_or((Color::BLACK, 1.0)); + match at(t).and_then(|v| colors.native(space, &v)) { + Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) }, + Some(Native { color, link: None }) => GradientStop::new(offset, color), + None => GradientStop::new(offset, rgb), + } }; let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64); let (kind, geom, stops) = if *axial { @@ -443,7 +463,7 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { } } -impl<'a> Device<'a> for Builder { +impl<'a> Device<'a> for Builder<'_> { fn set_soft_mask(&mut self, mask: Option>) { if mask.is_some() { self.warn("soft masks are not imported (content drawn unmasked)"); diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs new file mode 100644 index 00000000..c4966fbe --- /dev/null +++ b/crates/pdf/src/import_color.rs @@ -0,0 +1,419 @@ +//! PDF colours in their own models: DeviceCMYK and CMYK ICC colours stay CMYK, DeviceGray is +//! Gray, and Separation and DeviceN inks become spot swatches at a tint (for solid paints and +//! shading stops alike). Everything else is RGB, as the interpreter converts it. +//! +//! hayro-interpret keeps a colour's space and components private: their `Debug` form is the only +//! public view, so [`Colors`] reads that (`tests_import_color.rs` pins the format). Ink names +//! aren't in it at all: they come from the file's Separation and DeviceN arrays, found by their +//! tint transform. + +use std::fmt::{self, Debug, Write as _}; + +use hayro_interpret::Function; +use hayro_interpret::color::ColorSpace; +use hayro_syntax::Pdf; +use hayro_syntax::object::{Array, MaybeRef, Name, Object, Stream}; +use smallvec::SmallVec; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_color::{Color, Paint, Swatch}; + +/// The longest `Debug` text read: a colour whose space is longer (a big sampled tint transform) +/// imports as RGB. +const DEBUG_LIMIT: usize = 64 * 1024; +/// The deepest nesting searched for colour spaces, and the most ink spaces kept. +const MAX_DEPTH: u32 = 32; +const MAX_SPACES: usize = 4096; + +/// DeviceN (and Separation) colorants that are process plates, in CMYK channel order. +const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"]; + +fn round3(v: f32) -> f32 { + (v * 1000.0).round() / 1000.0 +} + +/// A colour in its own model; an ink links to its spot swatch at a tint. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Native { + pub color: Color, + pub link: Option<(String, f32)>, +} + +impl Native { + fn plain(color: Color) -> Self { + Self { color, link: None } + } + + pub fn paint(self) -> Paint { + match self.link { + Some((name, tint)) => Paint::Solid { color: self.color, swatch: Some(name), tint }, + None => Paint::solid(self.color), + } + } +} + +/// What a Separation or DeviceN colorant is. +#[derive(Clone, Copy, Debug)] +enum Colorant { + /// A spot ink (index into `Colors::inks`). + Ink(usize), + /// A process plate (CMYK channel). + Process(usize), + /// Every plate (Registration). + All, + /// Paints nothing. + None, + /// An ink whose colour can't be told (its alternate space isn't one we model). + Unknown, +} + +/// A Separation or DeviceN colour space of the file. +struct InkSpace { + /// `Debug` text of its tint transform: how a colour's space is matched to it. + key: String, + device_n: bool, + names: Vec, + colorants: Vec, +} + +struct Ink { + /// The colorant name in the file. + source: String, + /// The swatch name (the source name, made unique against the document's swatches). + name: String, + /// The colour at 100 %. + color: Color, + used: bool, +} + +/// How a colour space's components map. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + /// A Separation or DeviceN space (index into `Colors::spaces`). + Inks(usize), + /// Anything else: RGB through the interpreter. + Other, +} + +/// The alternate space of an ink, for its colour at 100 %. +#[derive(Clone, Copy)] +enum Alt { + Gray, + Rgb, + Cmyk, + Lab, +} + +/// The import's colour mapping, with the inks found and the colour models used. +pub(crate) struct Colors<'p> { + pdf: &'p Pdf, + /// Swatch names the document already has. + taken: Vec, + scanned: bool, + spaces: Vec, + inks: Vec, + /// Process colours painted in CMYK and in RGB (or anything converted to it). + cmyk: usize, + rgb: usize, + /// A DeviceN colour mixed several inks (imported as RGB). + pub mixed: bool, +} + +/// `v`'s `Debug` text, or `None` past [`DEBUG_LIMIT`] (formatting stops there). +fn debug_text(v: &impl Debug) -> Option { + struct Capped(String); + impl fmt::Write for Capped { + fn write_str(&mut self, s: &str) -> fmt::Result { + if self.0.len() + s.len() > DEBUG_LIMIT { + return Err(fmt::Error); + } + self.0.push_str(s); + Ok(()) + } + } + let mut w = Capped(String::new()); + write!(w, "{v:?}").ok()?; + Some(w.0) +} + +/// A colour's `Debug` text (`Color { color_space: …, components: [..], opacity: .. }`) split into +/// its space's text and its components. +pub(crate) fn split_color(t: &str) -> Option<(&str, Vec)> { + let start = t.find("color_space: ")? + "color_space: ".len(); + let at = t.rfind(", components: [")?; + let space = t.get(start..at)?; + let rest = t.get(at + ", components: [".len()..)?; + let list = rest.get(..rest.find(']')?)?; + let comps = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::parse::).collect::, _>>().ok()?; + comps.iter().all(|c| c.is_finite()).then_some((space, comps)) +} + +impl<'p> Colors<'p> { + pub fn new(pdf: &'p Pdf, taken: Vec) -> Self { + Self { pdf, taken, scanned: false, spaces: vec![], inks: vec![], cmyk: 0, rgb: 0, mixed: false } + } + + /// A solid colour in its own model; `None`: use its RGB. + pub fn solid(&mut self, c: &hayro_interpret::color::Color) -> Option { + let mapped = debug_text(c).and_then(|t| { + let (space, comps) = split_color(&t)?; + let space = self.space_of(space, comps.len()); + Some((space, comps)) + }); + let (space, comps) = mapped.unwrap_or((Space::Other, vec![])); + self.count(space); + self.native(space, &comps) + } + + /// The space shading colours are in, for [`Colors::native`]; `n`: its component count. + pub fn shading_space(&mut self, cs: &ColorSpace, n: usize) -> Space { + let space = debug_text(cs).map_or(Space::Other, |t| self.space_of(&t, n)); + self.count(space); + space + } + + /// Note a colour painted in `space` (the document's colour mode follows the majority). + fn count(&mut self, space: Space) { + match space { + Space::Cmyk => self.cmyk += 1, + Space::Rgb | Space::Other => self.rgb += 1, + Space::Gray | Space::Inks(_) => {} + } + } + + /// The document opens in CMYK when more process colours were CMYK than RGB. + pub fn cmyk_document(&self) -> bool { + self.cmyk > self.rgb + } + + /// Spot swatches for the inks used. + pub fn swatches(&self) -> impl Iterator + '_ { + self.inks.iter().filter(|i| i.used).map(|i| Swatch { name: i.name.clone(), paint: Paint::solid(i.color), global: true, spot: true }) + } + + /// The space a `ColorSpace(…)` `Debug` text names, with `n` components. + pub(crate) fn space_of(&mut self, text: &str, n: usize) -> Space { + let inner = text.strip_prefix("ColorSpace(").unwrap_or(text); + let kind = inner.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or_default(); + match (kind, n) { + ("DeviceGray" | "ICCBased", 1) => Space::Gray, + ("DeviceRgb", 3) => Space::Rgb, + ("DeviceCmyk" | "ICCBased", 4) => Space::Cmyk, + ("Separation", 1) | ("DeviceN", _) => { + self.scan(); + let device_n = kind == "DeviceN"; + let mut found = + self.spaces.iter().enumerate().filter(|(_, s)| s.device_n == device_n && s.colorants.len() == n && inner.contains(&s.key)); + match found.next() { + // Spaces of other inks with the same alternate and tint transform look the + // same here: which inks this colour uses can't be told. + Some((i, first)) if found.all(|(_, s)| s.names == first.names) => Space::Inks(i), + _ => Space::Other, + } + } + _ => Space::Other, + } + } + + /// Components `comps` of `space` in their own model; `None`: use their RGB. + pub fn native(&mut self, space: Space, comps: &[f32]) -> Option { + let c = |i: usize| round3(comps.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)); + match space { + Space::Gray => Some(Native::plain(Color::gray(round3(1.0 - c(0))))), + Space::Cmyk => Some(Native::plain(Color::cmyk(c(0), c(1), c(2), c(3)))), + Space::Rgb | Space::Other => None, + Space::Inks(i) => self.inks_color(i, comps), + } + } + + /// A Separation or DeviceN colour: process plates make a CMYK colour; one ink (or every + /// plate) at a tint links to its swatch; several inks together can't, and import as RGB. + fn inks_color(&mut self, space: usize, comps: &[f32]) -> Option { + let colorants = &self.spaces.get(space)?.colorants; + let mut cmyk = [0.0f32; 4]; + let mut ink: Option<(Colorant, f32)> = None; + let mut first_ink = None; + let mut several = false; + for (c, t) in colorants.iter().zip(comps) { + let t = round3(t.clamp(0.0, 1.0)); + match *c { + Colorant::Unknown => return None, + Colorant::None => {} + Colorant::Process(ch) => { + if let Some(v) = cmyk.get_mut(ch) { + *v = v.max(t); + } + } + Colorant::Ink(_) | Colorant::All => { + first_ink.get_or_insert(*c); + if t > 0.0 { + several |= ink.is_some(); + ink = Some((*c, t)); + } + } + } + } + let process = cmyk.iter().any(|v| *v > 0.0); + let (c, t) = match (ink, first_ink) { + (Some(_), _) if several || process => { + self.mixed = true; + return None; + } + (Some(it), _) => it, + // No ink is painting: 0 % of the first one (a tint of paper), unless the plates are + // all process ones. + (None, Some(c)) if !process => (c, 0.0), + _ => return Some(Native::plain(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]))), + }; + match c { + Colorant::Ink(k) => { + let ink = self.inks.get_mut(k)?; + ink.used = true; + Some(Native { color: ink.color.tinted(t), link: Some((ink.name.clone(), t)) }) + } + _ => Some(Native { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), link: Some((REGISTRATION.to_string(), t)) }), + } + } + + /// Find the file's Separation and DeviceN colour spaces (once, when a colour first needs them). + fn scan(&mut self) { + if self.scanned { + return; + } + self.scanned = true; + let pdf = self.pdf; + for o in pdf.objects() { + self.visit(o, 0); + } + } + + fn visit(&mut self, o: Object<'_>, depth: u32) { + if depth > MAX_DEPTH || self.spaces.len() >= MAX_SPACES { + return; + } + match o { + Object::Array(a) => { + self.ink_space(&a); + for item in a.raw_iter().filter_map(direct) { + self.visit(item, depth + 1); + } + } + Object::Dict(d) => { + for (_, v) in d.entries() { + if let Some(v) = direct(v) { + self.visit(v, depth + 1); + } + } + } + Object::Stream(s) => self.visit(Object::Dict(s.dict().clone()), depth + 1), + _ => {} + } + } + + /// Record `a` when it is a `[/Separation name alt tint]` or `[/DeviceN [names] alt tint …]` space. + fn ink_space(&mut self, a: &Array<'_>) -> Option<()> { + let mut it = a.flex_iter(); + let device_n = match it.next::>()?.as_str() { + "Separation" => false, + "DeviceN" => true, + _ => return None, + }; + let names: Vec = if device_n { + it.next::>()?.iter::>().map(|n| n.as_str().to_string()).collect() + } else { + vec![it.next::>()?.as_str().to_string()] + }; + let alt = alt_model(&it.next::>()?); + let tint = Function::new(&it.next::>()?)?; + let key = debug_text(&tint)?; + if names.is_empty() || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { + return None; + } + let colorants = names + .iter() + .enumerate() + .map(|(i, name)| match name.as_str() { + "None" => Colorant::None, + "All" => Colorant::All, + n => match PROCESS.iter().position(|p| *p == n) { + Some(ch) => Colorant::Process(ch), + None => { + // The ink alone at 100 %, through the tint transform. + let input: SmallVec<[f32; 4]> = (0..names.len()).map(|j| if i == j { 1.0 } else { 0.0 }).collect(); + match alt.zip(tint.eval(input)).and_then(|(alt, v)| alt_color(alt, &v)) { + Some(color) => Colorant::Ink(self.ink(n, color)), + None => Colorant::Unknown, + } + } + }, + }) + .collect(); + self.spaces.push(InkSpace { key, device_n, names, colorants }); + Some(()) + } + + /// The ink named `source` (the first definition wins), its swatch name unique in the document. + fn ink(&mut self, source: &str, color: Color) -> usize { + if let Some(i) = self.inks.iter().position(|k| k.source == source) { + return i; + } + let mut name = source.to_string(); + let mut n = 2; + while self.taken.contains(&name) { + name = format!("{source} {n}"); + n += 1; + } + self.inks.push(Ink { source: source.to_string(), name, color, used: false }); + self.inks.len() - 1 + } +} + +/// An object written in place (references are visited as objects of their own). +fn direct(m: MaybeRef>) -> Option> { + match m { + MaybeRef::NotRef(o) => Some(o), + MaybeRef::Ref(_) => None, + } +} + +fn alt_model(o: &Object<'_>) -> Option { + match o { + Object::Name(n) => match n.as_str() { + "DeviceGray" | "G" => Some(Alt::Gray), + "DeviceRGB" | "RGB" => Some(Alt::Rgb), + "DeviceCMYK" | "CMYK" => Some(Alt::Cmyk), + _ => None, + }, + Object::Array(a) => { + let mut it = a.flex_iter(); + match it.next::>()?.as_str() { + "ICCBased" => match it.next::>()?.dict().get::(b"N")? { + 1 => Some(Alt::Gray), + 3 => Some(Alt::Rgb), + 4 => Some(Alt::Cmyk), + _ => None, + }, + "Lab" => Some(Alt::Lab), + "CalRGB" => Some(Alt::Rgb), + "CalGray" => Some(Alt::Gray), + "CalCMYK" => Some(Alt::Cmyk), + _ => None, + } + } + _ => None, + } +} + +/// An alternate-space colour (the tint transform's output) as a VectorCraft colour. +fn alt_color(alt: Alt, v: &[f32]) -> Option { + let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite()); + let u = |i: usize| c(i).map(|x| round3(x.clamp(0.0, 1.0))); + Some(match alt { + Alt::Gray => Color::gray(round3(1.0 - u(0)?)), + Alt::Rgb => Color::rgb(u(0)?, u(1)?, u(2)?), + Alt::Cmyk => Color::cmyk(u(0)?, u(1)?, u(2)?, u(3)?), + Alt::Lab => Color::lab(round3(c(0)?.clamp(0.0, 100.0)), round3(c(1)?.clamp(-128.0, 127.0)), round3(c(2)?.clamp(-128.0, 127.0))), + }) +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index f87e9a2f..b7a33a24 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -11,12 +11,15 @@ //! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a //! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups, //! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others -//! re-encoded as PNG) and text as glyph outlines. [`ImportOptions`] pick the pages, the box each +//! re-encoded as PNG) and text as glyph outlines. Colours keep their model: CMYK, Gray, and spot +//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a +//! CMYK document. [`ImportOptions`] pick the pages, the box each //! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes. #![forbid(unsafe_code)] mod export; mod import; +mod import_color; mod lab_spot; mod pages; mod settings; @@ -138,6 +141,8 @@ mod tests_focal; #[cfg(test)] mod tests_fx; #[cfg(test)] +mod tests_import_color; +#[cfg(test)] mod tests_import_options; #[cfg(test)] mod tests_settings; diff --git a/crates/pdf/src/tests_import_color.rs b/crates/pdf/src/tests_import_color.rs new file mode 100644 index 00000000..4a0acc4d --- /dev/null +++ b/crates/pdf/src/tests_import_color.rs @@ -0,0 +1,217 @@ +//! Imported colours keep their model: CMYK, Gray, spot inks (Separation, DeviceN) at a tint, in +//! solid paints and shading stops; a CMYK file opens as a CMYK document. + +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch}; +use vectorcraft_doc::{Appearance, AppearanceItem, ColorMode, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with}; + +use crate::import_color::{Colors, Space}; +use crate::*; + +const Q: f32 = 1.0 / 255.0 + 1e-4; + +fn add(d: &mut Document, p: Paint) { + let x = d.node_count() as f64 * 12.0; + let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(p, Paint::None, 0.0)); + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +/// The fills of the imported page, in paint order. +fn fills(d: &Document) -> Vec { + let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() }; + children + .iter() + .filter_map(|n| n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None })) + .collect() +} + +fn near(a: Color, b: Color) -> bool { + match (a, b) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => [c - c2, m - m2, y - y2, k - k2].iter().all(|v| v.abs() <= Q), + (Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() <= Q, + (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|v| v.abs() <= 0.01), + (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => [r - r2, g - g2, b - b2].iter().all(|v| v.abs() <= Q), + _ => false, + } +} + +fn solid(p: &Paint) -> (Color, Option<&str>, f32) { + match p { + Paint::Solid { color, swatch, tint } => (*color, swatch.as_deref(), *tint), + other => panic!("{other:?}"), + } +} + +fn spot(name: &str, c: Color) -> Swatch { + Swatch { name: name.into(), paint: Paint::solid(c), global: true, spot: true } +} + +#[test] +fn a_cmyk_document_with_spot_colours_round_trips() { + let mut d = Document::new_with_mode(200.0, 50.0, ColorMode::Cmyk); + d.swatches.push(spot("Ink A", Color::cmyk(0.1, 0.9, 0.2, 0.0))); + d.swatches.push(spot("Lab Ink", Color::lab(55.0, 60.0, 40.0))); + add(&mut d, Paint::solid(Color::cmyk(0.1, 0.2, 0.3, 0.4))); + add(&mut d, Paint::solid(Color::gray(0.25))); + for (name, tint) in [("Ink A", 0.4), ("Lab Ink", 0.6), (REGISTRATION, 0.5)] { + let p = d.tint_paint(name, tint).unwrap(); + add(&mut d, p); + } + let bytes = export(&d, &PdfOptions::default()).unwrap(); + let r = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + let back = r.document; + assert_eq!(back.color_mode, ColorMode::Cmyk, "a CMYK file opens in CMYK"); + assert!(back.swatches.iter().filter_map(|s| s.paint.color()).any(|c| matches!(c, Color::Cmyk { .. })), "CMYK default swatches"); + let f = fills(&back); + assert_eq!(f.len(), 5); + assert!(near(solid(&f[0]).0, Color::cmyk(0.1, 0.2, 0.3, 0.4)), "{:?}", f[0]); + assert!(near(solid(&f[1]).0, Color::gray(0.25)), "{:?}", f[1]); + // Spot inks link to new spot swatches at their tint. + for (p, name, tint) in [(&f[2], "Ink A", 0.4), (&f[3], "Lab Ink", 0.6)] { + let (color, link, t) = solid(p); + assert_eq!(link, Some(name)); + assert!((t - tint).abs() <= Q, "{name}: tint {t}"); + let sw = back.swatch(name).unwrap(); + assert!(sw.spot && sw.global, "{name} is a spot swatch"); + assert!(near(color, sw.paint.color().unwrap().tinted(t)), "{name}: {color:?}"); + } + assert!(near(back.swatch("Ink A").unwrap().paint.color().unwrap(), Color::cmyk(0.1, 0.9, 0.2, 0.0))); + assert!(near(back.swatch("Lab Ink").unwrap().paint.color().unwrap(), Color::lab(55.0, 60.0, 40.0)), "a Lab alternate keeps Lab"); + // Registration (every plate) links to the built-in swatch, which isn't added. + let (_, link, t) = solid(&f[4]); + assert_eq!((link, (t * 100.0).round()), (Some(REGISTRATION), 50.0)); + assert_eq!(back.swatches.iter().filter(|s| s.spot).count(), 2); + assert!(r.warnings.iter().all(|w| !w.contains("inks")), "{:?}", r.warnings); +} + +#[test] +fn gray_imports_as_gray_and_rgb_documents_stay_rgb() { + let mut d = Document::new(100.0, 50.0); + add(&mut d, Paint::solid(Color::gray(0.8))); + add(&mut d, Paint::solid(Color::rgb(0.2, 0.4, 0.6))); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + assert_eq!(back.color_mode, ColorMode::Rgb); + let f = fills(&back); + assert!(near(solid(&f[0]).0, Color::gray(0.8)), "{:?}", f[0]); + assert!(near(solid(&f[1]).0, Color::rgb(0.2, 0.4, 0.6)), "{:?}", f[1]); + assert!(back.swatches.iter().all(|s| !s.spot)); +} + +#[test] +fn shading_stops_keep_cmyk_and_spot_tints() { + let mut d = Document::new_with_mode(100.0, 50.0, ColorMode::Cmyk); + d.swatches.push(spot("Ink A", Color::cmyk(0.0, 0.5, 1.0, 0.0))); + let grad = |stops: Vec| Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + add(&mut d, grad(vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 1.0, 0.2))])); + let ink = d.global_color("Ink A").unwrap(); + let linked = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink A".into()), tint, ..GradientStop::new(offset, ink.tinted(tint)) }; + add(&mut d, grad(vec![linked(0.0, 0.0), linked(1.0, 1.0)])); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let f = fills(&back); + let stops = |p: &Paint| match p { + Paint::Gradient(g) => g.gradient.stops.clone(), + other => panic!("{other:?}"), + }; + let s = stops(&f[0]); + assert!(near(s[0].color, Color::cmyk(1.0, 0.0, 0.0, 0.0)) && near(s.last().unwrap().color, Color::cmyk(0.0, 0.0, 1.0, 0.2)), "{s:?}"); + assert!(s.iter().all(|s| matches!(s.color, Color::Cmyk { .. }) && s.swatch.is_none())); + let s = stops(&f[1]); + assert!(s.iter().all(|s| s.swatch.as_deref() == Some("Ink A")), "{s:?}"); + assert!(s[0].tint.abs() <= Q && (s.last().unwrap().tint - 1.0).abs() <= Q, "{s:?}"); + assert!(near(s.last().unwrap().color, Color::cmyk(0.0, 0.5, 1.0, 0.0))); + assert!(back.swatch("Ink A").is_some_and(|w| w.spot)); +} + +/// A page painting with colour spaces `CS0`…: `fills` are `(space, components)`. +fn page_with(spaces: &str, fills: &[(&str, &str)]) -> PdfPage { + let content: String = fills.iter().enumerate().map(|(i, (cs, c))| format!("/{cs} cs {c} scn {} 0 10 10 re f\n", i * 12)).collect(); + PdfPage { resources: format!("/ColorSpace << {spaces} >>"), ..PdfPage::new(200.0, 50.0, &content) } +} + +#[test] +fn device_n_inks_become_spot_swatches_and_process_plates_cmyk() { + // Gold and Silver → CMYK (0, 0, gold, silver), through a calculator function. + let f = first_extra(1); + let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 16 >>\nstream\n{ 0 0 4 2 roll }\nendstream"; + // Cyan and Magenta → CMYK (cyan, magenta, 0, 0). + let process = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 9 >>\nstream\n{ 0 0 }\nendstream"; + let spaces = format!( + "/CS0 [/DeviceN [/Gold /Silver] /DeviceCMYK {f} 0 R] /CS1 [/DeviceN [/Cyan /Magenta] /DeviceCMYK {} 0 R] /CS2 [/Separation /Black /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>]", + f + 1 + ); + let page = page_with(&spaces, &[("CS0", "0.3 0"), ("CS0", "0 1"), ("CS0", "0.5 0.5"), ("CS1", "0.2 0.7"), ("CS2", "0.4"), ("CS0", "0 0")]); + let r = import_with_report(&pdf_with(&[page], &[func, process], None), &ImportOptions::default()).unwrap(); + let d = &r.document; + let f = fills(d); + assert_eq!(f.len(), 6); + assert_eq!(solid(&f[0]).1, Some("Gold")); + assert!((solid(&f[0]).2 - 0.3).abs() < 1e-6); + assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Silver"), 1.0)); + assert!(near(d.swatch("Gold").unwrap().paint.color().unwrap(), Color::cmyk(0.0, 0.0, 1.0, 0.0))); + assert!(near(d.swatch("Silver").unwrap().paint.color().unwrap(), Color::cmyk(0.0, 0.0, 0.0, 1.0))); + // Two inks at once can't link to one swatch: RGB, with a warning. + assert!(matches!(solid(&f[2]), (Color::Rgb { .. }, None, _))); + assert!(r.warnings.iter().any(|w| w.contains("several inks")), "{:?}", r.warnings); + // Process plates are CMYK, also as a Separation. + assert!(near(solid(&f[3]).0, Color::cmyk(0.2, 0.7, 0.0, 0.0)), "{:?}", f[3]); + assert!(near(solid(&f[4]).0, Color::cmyk(0.0, 0.0, 0.0, 0.4)), "{:?}", f[4]); + // No ink painting: 0 % of the first. + assert_eq!((solid(&f[5]).1, solid(&f[5]).2), (Some("Gold"), 0.0)); + assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 2); +} + +#[test] +fn inks_that_cannot_be_told_apart_import_as_rgb() { + // Two spaces of other inks with the same alternate and tint transform. + let tint = "/DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>"; + let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS1 [/Separation /Rubyish {tint}] /CS2 [/Separation /Tealish {tint}]"); + let d = import(&pdf(&[page_with(&spaces, &[("CS0", "1")])], None)).unwrap(); + assert!(matches!(solid(&fills(&d)[0]), (Color::Rgb { .. }, None, _))); + // The same ink defined twice is still that ink. + let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS2 [/Separation /Tealish {tint}]"); + let d = import(&pdf(&[page_with(&spaces, &[("CS2", "0.5")])], None)).unwrap(); + assert_eq!(solid(&fills(&d)[0]).1, Some("Tealish")); +} + +#[test] +fn ink_names_never_clash_with_the_document_swatches() { + let taken = Document::new(1.0, 1.0).swatches.iter().find(|s| !s.paint.is_none() && s.name != REGISTRATION).unwrap().name.clone(); + let spaces = format!( + "/CS0 [/Separation /{} /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>]", + taken.replace(' ', "#20").replace('=', "#3D") + ); + let d = import(&pdf(&[page_with(&spaces, &[("CS0", "1")])], None)).unwrap(); + let f = fills(&d); + let (color, link, _) = solid(&f[0]); + let renamed = format!("{taken} 2"); + assert_eq!(link, Some(renamed.as_str())); + assert!(near(color, Color::rgb(1.0, 0.0, 0.0))); + assert!(d.swatch(&renamed).unwrap().spot); +} + +#[test] +fn colour_spaces_are_read_from_the_interpreters_debug_form() { + let pdf = hayro_syntax::Pdf::new(pdf(&[PdfPage::new(10.0, 10.0, "")], None)).unwrap(); + let mut c = Colors::new(&pdf, vec![]); + for (text, n, want) in [ + ("ColorSpace(DeviceCmyk)", 4, Space::Cmyk), + ("ColorSpace(DeviceGray)", 1, Space::Gray), + ("ColorSpace(DeviceRgb)", 3, Space::Rgb), + ("ColorSpace(ICCBased(ICCColor {..}))", 4, Space::Cmyk), + ("ColorSpace(ICCBased(ICCColor {..}))", 1, Space::Gray), + ("ColorSpace(ICCBased(ICCColor {..}))", 3, Space::Other), + ("ColorSpace(Lab(Lab { range: [] }))", 3, Space::Other), + ("ColorSpace(Separation(Separation { tint_transform: Function(Type2(..)) }))", 1, Space::Other), + ("ColorSpace(DeviceCmyk)", 3, Space::Other), + ] { + assert_eq!(c.space_of(text, n), want, "{text}"); + } + let t = "Color { color_space: ColorSpace(DeviceCmyk), components: [0.101960786, 0.2, 0.3, 0.4], opacity: 1.0 }"; + let (space, comps) = crate::import_color::split_color(t).unwrap(); + assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4])); + assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None); +} diff --git a/crates/testkit/src/pdf.rs b/crates/testkit/src/pdf.rs index 4c0d8d06..e4139823 100644 --- a/crates/testkit/src/pdf.rs +++ b/crates/testkit/src/pdf.rs @@ -52,6 +52,17 @@ fn rect(r: [f64; 4]) -> String { /// A PDF of `pages`; with a `password` it is encrypted and opens only with it (its owner password /// is the same followed by `-owner`). pub fn pdf(pages: &[PdfPage], password: Option<&str>) -> Vec { + pdf_with(pages, &[], password) +} + +/// The object number of the first `extra` object of [`pdf_with`] for a file of `pages` pages. +pub fn first_extra(pages: usize) -> usize { + 3 + 2 * pages +} + +/// [`pdf`] with `extra` objects (written as given, not encrypted: streams such as functions and +/// ICC profiles that resources refer to by number, from [`first_extra`] on). +pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Vec { let id = md5(b"vectorcraft test file"); // (O, U, file key) of the standard security handler, revision 2, permissions -4. let crypt = password.map(|pw| { @@ -96,6 +107,7 @@ pub fn pdf(pages: &[PdfPage], password: Option<&str>) -> Vec { s.extend_from_slice(b"\nendstream"); objects.push(s); } + objects.extend(extra.iter().map(|o| o.as_bytes().to_vec())); if let Some((o, u, _)) = &crypt { objects.push(format!("<< /Filter /Standard /V 1 /R 2 /O <{}> /U <{}> /P -4 >>", hex(o), hex(u)).into_bytes()); } diff --git a/docs/mcp.md b/docs/mcp.md index 08ccdf32..738ce472 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -188,7 +188,10 @@ options accepted but not applied yet, and features approximated or left out. PDF Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), `cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets) and `password` for an encrypted file. `document.pdfInfo` reads a file without opening it: the page count, each page's -size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n: +size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n. Imported colours keep their model: +DeviceCMYK and CMYK ICC colours stay CMYK (a file painted mostly in CMYK opens as a CMYK document), DeviceGray is Gray, +and Separation and DeviceN inks become spot swatches the art links to at its tint (gradient stops too). +`colorMode: "rgb" | "cmyk"` opens any file in that mode instead, its colours converted as `file.documentColorMode` does: ```json {"name":"run_command","arguments":{"command":"document.pdfInfo","params":{"path":"/tmp/brochure.pdf","thumbnail":2,"cropTo":"trim"}}} From e0895fd4bafc460ab44fa6a66b010d405746c8bf Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:57:31 +0700 Subject: [PATCH 3/3] M4.47: PostScript .ai and placeholder-only .ai files say why they can't open A PostScript file (a .ai saved in an older format or without PDF compatibility, or an .eps, plain or with a binary header) is refused by any name with a clear message instead of a parse error (there is no PostScript importer yet). An .ai whose pages are only placeholder text over the editor's private data (PieceInfo AIPrivateData) is detected and refused with advice to save it with PDF compatibility, instead of opening the placeholder. vectorcraft-pdf: is_postscript(), PdfError::PostScript / PlaceholderOnly. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/load.rs | 5 +++ .../engine/src/cmd/fileio/tests_pdfimport.rs | 41 +++++++++++++++++++ crates/pdf/src/import.rs | 28 ++++++++++++- crates/pdf/src/lib.rs | 10 ++++- crates/pdf/src/pages.rs | 18 ++++++++ crates/pdf/src/tests_import_options.rs | 8 ++++ crates/testkit/src/pdf.rs | 5 +++ docs/mcp.md | 3 ++ 8 files changed, 116 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index f993bb4a..43d462c6 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -80,6 +80,11 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { /// [`load`] with `document.open` options (the PDF pages, box and password). pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result { + // PostScript (.ai saved in an older format or without PDF compatibility, .eps) says so, by + // any name. + if vectorcraft_pdf::is_postscript(bytes) { + return Err(err(format!("can't open `{}`: {}", file_name(name), vectorcraft_pdf::PdfError::PostScript))); + } let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; let title = file_name(name); let mut warnings = vec![]; diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs index c8853c27..09437fab 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -146,3 +146,44 @@ fn load_options_read_pages_crop_and_password() { assert_eq!((o.pages.as_deref(), o.password), (Some("1-2"), None)); assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default()); } + +#[test] +fn a_postscript_ai_file_says_it_cannot_be_opened_yet() { + let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 lineto stroke\nshowpage\n%%EOF\n"; + let mut s = Session::new(); + for name in ["legacy.ai", "art.eps", "named.pdf", "template.ait"] { + let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string(); + assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}"); + } + // An EPS with a binary header too, and the PDF info of either. + let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6]; + binary.extend_from_slice(&[0; 28]); + binary.extend_from_slice(ps); + assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript")); + assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript")); + assert!(s.active().is_none()); +} + +/// A page showing only text, with an editor's private data when `private`. +fn text_page(private: bool, also: &str) -> PdfPage { + PdfPage { + resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(), + entries: if private { "/PieceInfo << /Editor << /Private << /AIPrivateData1 7 /RoundTripVersion 24 >> >> >>".into() } else { String::new() }, + ..PdfPage::new(300.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td (Saved without PDF content) Tj ET {also}")) + } +} + +#[test] +fn a_placeholder_only_pdf_part_is_detected() { + let mut s = Session::new(); + let e = open(&mut s, &pdf(&[text_page(true, "")], None), json!({})).unwrap_err().to_string(); + assert!(e.contains("placeholder page") && e.contains("PDF compatibility"), "{e}"); + assert!(s.active().is_none()); + // Text without private data, and private data with art, are ordinary files. + open(&mut s, &pdf(&[text_page(false, "")], None), json!({})).unwrap(); + open(&mut s, &pdf(&[text_page(true, "0 g 0 0 10 10 re f")], None), json!({})).unwrap(); + // One real page among the pages picked is enough. + let two = pdf(&[text_page(true, ""), text_page(true, "0 g 0 0 10 10 re f")], None); + assert!(open(&mut s, &two, json!({"pages": "1"})).is_err()); + open(&mut s, &two, json!({})).unwrap(); +} diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index fcd5947d..8fc6b143 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -22,6 +22,9 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::{CropTo, ImportOptions, ImportReport, PdfError}; +/// The name of the paths text imports as. +const TEXT_OUTLINES: &str = ""; + /// Import a PDF (or PDF-compatible `.ai`) with default options. pub fn import(bytes: &[u8]) -> Result { import_with_report(bytes, &ImportOptions::default()).map(|r| r.document) @@ -59,6 +62,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result Result Result]) -> bool { + fn walk(nodes: &[Arc], text: &mut bool) -> bool { + nodes.iter().all(|n| match &n.kind { + NodeKind::Path { clipping: true, .. } => true, + NodeKind::Path { .. } if n.name.as_deref() == Some(TEXT_OUTLINES) => { + *text = true; + true + } + NodeKind::Group { children, .. } => walk(children, text), + _ => false, + }) + } + let mut text = false; + walk(nodes, &mut text) && text +} + /// Decoded RGBA pixels, width, height and hayro's scale factors. type Decoded = (Vec, u32, u32, (f32, f32)); @@ -334,7 +360,7 @@ impl<'p> Builder<'p> { } let id = self.id(); let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap); - n.name = Some("".into()); + n.name = Some(TEXT_OUTLINES.into()); self.push_node(n); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b7a33a24..5a7a6417 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -26,7 +26,7 @@ mod settings; pub use export::{export, export_with_report}; pub use import::{import, import_with_report}; -pub use pages::{PageInfo, PdfInfo, info}; +pub use pages::{PageInfo, PdfInfo, info, is_postscript}; pub use settings::*; use vectorcraft_doc::Document; @@ -124,6 +124,14 @@ pub enum PdfError { WrongPassword, #[error("page {0} does not exist (the PDF has {1})")] BadPage(usize, usize), + #[error( + "this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG" + )] + PostScript, + #[error( + "the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG" + )] + PlaceholderOnly, } #[cfg(test)] diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 6e674459..5ddfcb75 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -1,6 +1,7 @@ //! The pages of a PDF to import: opening with a password, the pages picked, the box each page //! is cropped to ([`CropTo`]) and [`info`]. +use hayro_syntax::object::Dict; use hayro_syntax::page::Page; use hayro_syntax::{DecryptionError, LoadPdfError, Pdf}; use kurbo::{Affine, Rect}; @@ -26,8 +27,16 @@ pub struct PdfInfo { pub pages: Vec, } +/// Is this a PostScript file (plain, or an EPS with a binary header) rather than a PDF? +pub fn is_postscript(bytes: &[u8]) -> bool { + bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) +} + /// Read a PDF, decrypting it with `password` (none: the empty user password). pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { + if is_postscript(bytes) { + return Err(PdfError::PostScript); + } let password = password.unwrap_or_default(); Pdf::new_with_password(bytes.to_vec(), password).map_err(|e| match e { LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword, @@ -107,3 +116,12 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) { let (w, h) = page.render_dimensions(); (init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) }) } + +/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)? +/// A file saved without PDF compatibility keeps its art there and shows a placeholder page. +pub(crate) fn has_private_data(page: &Page<'_>) -> bool { + fn walk(d: &Dict<'_>, depth: u32) -> bool { + depth < 4 && d.keys().any(|k| k.starts_with(b"AIPrivateData") || d.get::>(k.as_ref()).is_some_and(|sub| walk(&sub, depth + 1))) + } + page.raw().get::>(b"PieceInfo").is_some_and(|d| walk(&d, 0)) +} diff --git a/crates/pdf/src/tests_import_options.rs b/crates/pdf/src/tests_import_options.rs index 43f31738..84e6b396 100644 --- a/crates/pdf/src/tests_import_options.rs +++ b/crates/pdf/src/tests_import_options.rs @@ -148,3 +148,11 @@ fn a_page_without_area_gets_the_size_viewers_give_it() { assert_eq!((ab.width(), ab.height()), (w, h), "{crop:?}"); } } + +#[test] +fn postscript_is_refused_as_such() { + let ps = b"%!PS\n0 0 moveto 10 10 lineto stroke\nshowpage\n"; + assert_eq!(import(ps).unwrap_err(), PdfError::PostScript); + assert_eq!(info(ps, None).unwrap_err(), PdfError::PostScript); + assert!(is_postscript(&[0xC5, 0xD0, 0xD3, 0xC6, 0, 0]) && !is_postscript(b"%PDF-1.7")); +} diff --git a/crates/testkit/src/pdf.rs b/crates/testkit/src/pdf.rs index e4139823..2dccda88 100644 --- a/crates/testkit/src/pdf.rs +++ b/crates/testkit/src/pdf.rs @@ -17,6 +17,8 @@ pub struct PdfPage { pub resources: String, /// The content stream (operators, uncompressed). pub content: String, + /// More entries of the page dictionary (`/PieceInfo << … >>`). + pub entries: String, } impl PdfPage { @@ -90,6 +92,9 @@ pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Ve if p.rotate != 0 { let _ = write!(d, " /Rotate {}", p.rotate); } + if !p.entries.is_empty() { + let _ = write!(d, " {}", p.entries); + } d.push_str(" >>"); objects.push(d.into_bytes()); let num = objects.len() + 1; diff --git a/docs/mcp.md b/docs/mcp.md index 738ce472..182b1655 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -198,6 +198,9 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","pages":"2-3","cropTo":"trim"}}} ``` +PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and +say so, as does an `.ai` whose PDF part is only a placeholder page. + Drive a tool like a mouse: ```json