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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 08:57:31 +07:00
parent 8765d335ec
commit e0895fd4ba
8 changed files with 116 additions and 2 deletions

View File

@ -80,6 +80,11 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
/// [`load`] with `document.open` options (the PDF pages, box and password).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
// 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![];

View File

@ -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();
}

View File

@ -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 = "<Text Outlines>";
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
import_with_report(bytes, &ImportOptions::default()).map(|r| r.document)
@ -59,6 +62,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken));
let cache = InterpreterCache::new();
let mut x = 0.0;
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
let mut placeholder = true;
for (i, &number) in picked.iter().enumerate() {
let Some(page) = pages.get(number) else { continue };
// The chosen box sits at (x, 0); the page draws round it.
@ -70,6 +75,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
b.begin_page();
interpret_page(page, &mut ctx, &mut b);
let children = b.end_page();
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
let mut right = ab.x1;
if opts.crop == CropTo::Bounding
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
@ -91,6 +97,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
doc.layers.push(Arc::new(layer));
}
if placeholder {
return Err(PdfError::PlaceholderOnly);
}
for (k, blob) in b.images.drain() {
doc.images.insert(k, blob);
}
@ -116,6 +125,23 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
Ok(ImportReport { document: doc, warnings })
}
/// Is this art text and nothing else (in groups and clips), with some text?
fn only_text(nodes: &[Arc<Node>]) -> bool {
fn walk(nodes: &[Arc<Node>], 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<u8>, 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("<Text Outlines>".into());
n.name = Some(TEXT_OUTLINES.into());
self.push_node(n);
}

View File

@ -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)]

View File

@ -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<PageInfo>,
}
/// 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<Pdf, PdfError> {
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::<Dict<'_>>(k.as_ref()).is_some_and(|sub| walk(&sub, depth + 1)))
}
page.raw().get::<Dict<'_>>(b"PieceInfo").is_some_and(|d| walk(&d, 0))
}

View File

@ -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"));
}

View File

@ -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;

View File

@ -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