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