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 f993bb4a6..43d462c68 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 c8853c275..09437fabe 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 fcd5947d8..8fc6b1430 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 b7a33a244..5a7a64178 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 6e674459b..5ddfcb757 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 43f317380..84e6b3961 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 e4139823e..2dccda88e 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 738ce472d..182b16556 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