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/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 13d4cb4b..43d462c6 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,16 @@ 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 { + // 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![]; @@ -102,13 +113,16 @@ 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))), }; + 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() { @@ -120,7 +134,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 +175,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..dc8d490c 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), 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 ), @@ -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,10 @@ mod tests; #[cfg(test)] mod tests_pdf; #[cfg(test)] +mod tests_pdfcolor; +#[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..3023bd87 --- /dev/null +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -0,0 +1,116 @@ +//! 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::{ColorMode, Document}; +use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError}; + +use super::super::*; +use super::{err, source}; + +/// 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. + 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, + /// 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`), `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, + 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); + 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. + 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_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 new file mode 100644 index 00000000..09437fab --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -0,0 +1,189 @@ +//! `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()), ..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)); + 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/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..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(), ]) } @@ -328,6 +336,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..8fc6b143 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -10,17 +10,20 @@ 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}; 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::{ImportOptions, ImportReport, PdfError}; +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 { @@ -29,12 +32,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(); @@ -58,14 +58,32 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> 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)); @@ -131,7 +172,8 @@ struct GlyphRun { scale: f64, } -struct Builder { +struct Builder<'p> { + colors: Colors<'p>, next: u64, stack: Vec, blend: BlendMode, @@ -199,9 +241,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, @@ -317,7 +360,7 @@ impl Builder { } 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); } @@ -327,10 +370,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"); @@ -353,7 +397,8 @@ impl Builder { self.warn("an image could not be decoded and was skipped"); return; }; - let k = format!("pdf-image-{}", self.image_keys.len() + 1); + // Content keys: placing this page into another document merges its images safely. + let k = blob.content_key(); self.images.insert(k.clone(), blob); self.image_keys.insert(key, (k.clone(), w, h)); (k, w, h) @@ -363,17 +408,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. @@ -395,9 +442,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 { @@ -438,7 +489,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 7931df82..5a7a6417 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -11,16 +11,22 @@ //! - [`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. +//! 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; pub use export::{export, export_with_report}; pub use import::{import, import_with_report}; +pub use pages::{PageInfo, PdfInfo, info, is_postscript}; pub use settings::*; use vectorcraft_doc::Document; @@ -49,18 +55,39 @@ impl PdfOptions { /// Import options. #[derive(Clone, Debug)] pub struct ImportOptions { - /// Import at most this many pages (from the first); `None` = all pages. + /// Import at most this many pages (of those picked); `None` = all of them. pub max_pages: Option, /// 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 +118,20 @@ 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), + #[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)] @@ -108,6 +149,10 @@ 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; #[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..5ddfcb75 --- /dev/null +++ b/crates/pdf/src/pages.rs @@ -0,0 +1,127 @@ +//! 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}; + +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, +} + +/// 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, + 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) }) +} + +/// 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/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_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/pdf/src/tests_import_options.rs b/crates/pdf/src/tests_import_options.rs new file mode 100644 index 00000000..84e6b396 --- /dev/null +++ b/crates/pdf/src/tests_import_options.rs @@ -0,0 +1,158 @@ +//! 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:?}"); + } +} + +#[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/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..2dccda88 --- /dev/null +++ b/crates/testkit/src/pdf.rs @@ -0,0 +1,206 @@ +//! 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, + /// More entries of the page dictionary (`/PieceInfo << … >>`). + pub entries: 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 { + 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| { + 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); + } + if !p.entries.is_empty() { + let _ = write!(d, " {}", p.entries); + } + 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); + } + 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()); + } + 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..182b1655 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -185,6 +185,22 @@ 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. 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"}}} +{"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