diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index f993bb4a..43d462c6 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -80,6 +80,11 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { /// [`load`] with `document.open` options (the PDF pages, box and password). pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result { + // PostScript (.ai saved in an older format or without PDF compatibility, .eps) says so, by + // any name. + if vectorcraft_pdf::is_postscript(bytes) { + return Err(err(format!("can't open `{}`: {}", file_name(name), vectorcraft_pdf::PdfError::PostScript))); + } let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; let title = file_name(name); let mut warnings = vec![]; diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs index c8853c27..09437fab 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -146,3 +146,44 @@ fn load_options_read_pages_crop_and_password() { assert_eq!((o.pages.as_deref(), o.password), (Some("1-2"), None)); assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default()); } + +#[test] +fn a_postscript_ai_file_says_it_cannot_be_opened_yet() { + let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 lineto stroke\nshowpage\n%%EOF\n"; + let mut s = Session::new(); + for name in ["legacy.ai", "art.eps", "named.pdf", "template.ait"] { + let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string(); + assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}"); + } + // An EPS with a binary header too, and the PDF info of either. + let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6]; + binary.extend_from_slice(&[0; 28]); + binary.extend_from_slice(ps); + assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript")); + assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript")); + assert!(s.active().is_none()); +} + +/// A page showing only text, with an editor's private data when `private`. +fn text_page(private: bool, also: &str) -> PdfPage { + PdfPage { + resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(), + entries: if private { "/PieceInfo << /Editor << /Private << /AIPrivateData1 7 /RoundTripVersion 24 >> >> >>".into() } else { String::new() }, + ..PdfPage::new(300.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td (Saved without PDF content) Tj ET {also}")) + } +} + +#[test] +fn a_placeholder_only_pdf_part_is_detected() { + let mut s = Session::new(); + let e = open(&mut s, &pdf(&[text_page(true, "")], None), json!({})).unwrap_err().to_string(); + assert!(e.contains("placeholder page") && e.contains("PDF compatibility"), "{e}"); + assert!(s.active().is_none()); + // Text without private data, and private data with art, are ordinary files. + open(&mut s, &pdf(&[text_page(false, "")], None), json!({})).unwrap(); + open(&mut s, &pdf(&[text_page(true, "0 g 0 0 10 10 re f")], None), json!({})).unwrap(); + // One real page among the pages picked is enough. + let two = pdf(&[text_page(true, ""), text_page(true, "0 g 0 0 10 10 re f")], None); + assert!(open(&mut s, &two, json!({"pages": "1"})).is_err()); + open(&mut s, &two, json!({})).unwrap(); +} diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index fcd5947d..8fc6b143 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -22,6 +22,9 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::{CropTo, ImportOptions, ImportReport, PdfError}; +/// The name of the paths text imports as. +const TEXT_OUTLINES: &str = ""; + /// Import a PDF (or PDF-compatible `.ai`) with default options. pub fn import(bytes: &[u8]) -> Result { import_with_report(bytes, &ImportOptions::default()).map(|r| r.document) @@ -59,6 +62,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result Result Result]) -> bool { + fn walk(nodes: &[Arc], text: &mut bool) -> bool { + nodes.iter().all(|n| match &n.kind { + NodeKind::Path { clipping: true, .. } => true, + NodeKind::Path { .. } if n.name.as_deref() == Some(TEXT_OUTLINES) => { + *text = true; + true + } + NodeKind::Group { children, .. } => walk(children, text), + _ => false, + }) + } + let mut text = false; + walk(nodes, &mut text) && text +} + /// Decoded RGBA pixels, width, height and hayro's scale factors. type Decoded = (Vec, u32, u32, (f32, f32)); @@ -334,7 +360,7 @@ impl<'p> Builder<'p> { } let id = self.id(); let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap); - n.name = Some("".into()); + n.name = Some(TEXT_OUTLINES.into()); self.push_node(n); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b7a33a24..5a7a6417 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -26,7 +26,7 @@ mod settings; pub use export::{export, export_with_report}; pub use import::{import, import_with_report}; -pub use pages::{PageInfo, PdfInfo, info}; +pub use pages::{PageInfo, PdfInfo, info, is_postscript}; pub use settings::*; use vectorcraft_doc::Document; @@ -124,6 +124,14 @@ pub enum PdfError { WrongPassword, #[error("page {0} does not exist (the PDF has {1})")] BadPage(usize, usize), + #[error( + "this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG" + )] + PostScript, + #[error( + "the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG" + )] + PlaceholderOnly, } #[cfg(test)] diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 6e674459..5ddfcb75 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -1,6 +1,7 @@ //! The pages of a PDF to import: opening with a password, the pages picked, the box each page //! is cropped to ([`CropTo`]) and [`info`]. +use hayro_syntax::object::Dict; use hayro_syntax::page::Page; use hayro_syntax::{DecryptionError, LoadPdfError, Pdf}; use kurbo::{Affine, Rect}; @@ -26,8 +27,16 @@ pub struct PdfInfo { pub pages: Vec, } +/// Is this a PostScript file (plain, or an EPS with a binary header) rather than a PDF? +pub fn is_postscript(bytes: &[u8]) -> bool { + bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) +} + /// Read a PDF, decrypting it with `password` (none: the empty user password). pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { + if is_postscript(bytes) { + return Err(PdfError::PostScript); + } let password = password.unwrap_or_default(); Pdf::new_with_password(bytes.to_vec(), password).map_err(|e| match e { LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword, @@ -107,3 +116,12 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) { let (w, h) = page.render_dimensions(); (init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) }) } + +/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)? +/// A file saved without PDF compatibility keeps its art there and shows a placeholder page. +pub(crate) fn has_private_data(page: &Page<'_>) -> bool { + fn walk(d: &Dict<'_>, depth: u32) -> bool { + depth < 4 && d.keys().any(|k| k.starts_with(b"AIPrivateData") || d.get::>(k.as_ref()).is_some_and(|sub| walk(&sub, depth + 1))) + } + page.raw().get::>(b"PieceInfo").is_some_and(|d| walk(&d, 0)) +} diff --git a/crates/pdf/src/tests_import_options.rs b/crates/pdf/src/tests_import_options.rs index 43f31738..84e6b396 100644 --- a/crates/pdf/src/tests_import_options.rs +++ b/crates/pdf/src/tests_import_options.rs @@ -148,3 +148,11 @@ fn a_page_without_area_gets_the_size_viewers_give_it() { assert_eq!((ab.width(), ab.height()), (w, h), "{crop:?}"); } } + +#[test] +fn postscript_is_refused_as_such() { + let ps = b"%!PS\n0 0 moveto 10 10 lineto stroke\nshowpage\n"; + assert_eq!(import(ps).unwrap_err(), PdfError::PostScript); + assert_eq!(info(ps, None).unwrap_err(), PdfError::PostScript); + assert!(is_postscript(&[0xC5, 0xD0, 0xD3, 0xC6, 0, 0]) && !is_postscript(b"%PDF-1.7")); +} diff --git a/crates/testkit/src/pdf.rs b/crates/testkit/src/pdf.rs index e4139823..2dccda88 100644 --- a/crates/testkit/src/pdf.rs +++ b/crates/testkit/src/pdf.rs @@ -17,6 +17,8 @@ pub struct PdfPage { pub resources: String, /// The content stream (operators, uncompressed). pub content: String, + /// More entries of the page dictionary (`/PieceInfo << … >>`). + pub entries: String, } impl PdfPage { @@ -90,6 +92,9 @@ pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Ve if p.rotate != 0 { let _ = write!(d, " /Rotate {}", p.rotate); } + if !p.entries.is_empty() { + let _ = write!(d, " {}", p.entries); + } d.push_str(" >>"); objects.push(d.into_bytes()); let num = objects.len() + 1; diff --git a/docs/mcp.md b/docs/mcp.md index 738ce472..182b1655 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -198,6 +198,9 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","pages":"2-3","cropTo":"trim"}}} ``` +PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and +say so, as does an `.ai` whose PDF part is only a placeholder page. + Drive a tool like a mouse: ```json