Part of #637: .ai files saved without PDF compatibility open from their editing data

Such a file's PDF part is a placeholder page, which was refused. When
the file carries editing data, it now opens from that alone: its
structure as a PDF-compatible file's, its point type made from the text
document, and type it can't make (area type, type on a path) left out
with a note that marks the import as lossy. Without editing data, or
with pages picked or layers off, the placeholder is refused as before;
damaged editing data says so.

The page-order note is no longer given when the page has no type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 16:35:22 +07:00
parent 008b5e6d6c
commit 4b9f9b9bc2
12 changed files with 122 additions and 41 deletions

File diff suppressed because one or more lines are too long

View File

@ -150,6 +150,20 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
(doc, warnings)
}
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers are
/// off), an error when it can't be read.
fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(Document, Vec<String>)>> {
if opts.pages.is_some() || !opts.layers {
return None;
}
let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?;
Some(
vectorcraft_eps::ai_alone(&private)
.map_err(|why| err(format!("this file was saved without PDF compatibility, and its editing data can't be read ({why})"))),
)
}
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
@ -188,9 +202,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || {
let (doc, warnings) = super::pdfimport::import(bytes, opts)?;
Ok(through_editing_data(bytes, opts, doc, warnings))
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
}
Err(e) => Err(e),
})?
}
"dxf" => {

View File

@ -91,3 +91,20 @@ fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
}
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
#[test]
fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
let mut s = Session::new();
let r = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
check_sample(&s);
// Its editing data damaged: it says so (the placeholder isn't the art).
let e = open(&mut s, "damaged.ai", &ai::ai(b"%AI12_CompressedData not zlib", PLACEHOLDER), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
// Without editing data, the placeholder still isn't opened as the art.
let e = open(&mut s, "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}

View File

@ -61,7 +61,7 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
pub use native::{is_loss, layered_ai};
pub use native::{ai_alone, is_loss, layered_ai};
pub use text::family_style;
/// The page of a PostScript file without a bounding box (US Letter).

View File

@ -31,6 +31,8 @@ use super::ate::{Story, Texts};
/// The notes that say the import left out something the file has (see [`is_loss`]).
const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out";
/// The same for a file without a page to take shown type from.
const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn't read from the file's text document yet are left out";
const ART_LEFT_OUT: &str = "hidden layers have art this can't read";
const LAYERS_UNREAD: &str = "the file's layers weren't read";
@ -38,6 +40,7 @@ const LAYERS_UNREAD: &str = "the file's layers weren't read";
/// layers that could not be read)? Writing the document over the file would lose it for good.
pub fn is_loss(note: &str) -> bool {
note.ends_with(TEXT_LEFT_OUT)
|| note.ends_with(TYPE_LEFT_OUT)
|| [ART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
}
@ -512,7 +515,7 @@ fn plan(
};
}
}
if spare.len() != loose.len() {
if !spare.is_empty() && spare.len() != loose.len() {
warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into());
}
}
@ -600,31 +603,36 @@ struct Frame<'a> {
}
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
/// file read as its page, put in its text objects → the document, its notes and where the page is
/// in it.
fn build(data: &[u8], visible: &Document, page: Page) -> Result<(Document, Vec<String>, Rect), String> {
/// file read as its page, put in its text objects → the document and its notes. Without a page
/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and
/// nothing is compared.
fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Document, Vec<String>), String> {
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?;
let art_box = match page {
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?,
};
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.height()).abs() > 0.5 {
return Err("its editing data is for a different page".into());
}
// The page's area in the layers' document, and the page's type moved there.
let rect = to_doc.transform_rect_bbox(art_box);
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
let (mut shown, mut hidden) = (vec![], vec![]);
for l in &visible.layers {
if let Some(c) = l.children() {
text_objects(c, l.visible, &mut shown, &mut hidden);
let mut frame = None;
if let Some(visible) = visible {
let art_box = match page {
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?,
};
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.height()).abs() > 0.5 {
return Err("its editing data is for a different page".into());
}
}
for object in shown.iter_mut().chain(hidden.iter_mut()) {
for n in object.iter_mut() {
Arc::make_mut(n).transform(shift, false);
// The page's area in the layers' document, and the page's type moved there.
let rect = to_doc.transform_rect_bbox(art_box);
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
for l in &visible.layers {
if let Some(c) = l.children() {
text_objects(c, l.visible, &mut shown, &mut hidden);
}
}
for object in shown.iter_mut().chain(hidden.iter_mut()) {
for n in object.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}
}
frame = Some(Frame { page: visible, page_rect, rect });
}
let mut infos = vec![];
collect_slots(&doc.layers, true, &mut infos);
@ -656,18 +664,22 @@ fn build(data: &[u8], visible: &Document, page: Page) -> Result<(Document, Vec<S
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty);
doc.layers = layers;
if empty > 0 {
notes.push(format!("{empty} {TEXT_LEFT_OUT}"));
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
}
if !hidden_unread.is_empty() {
notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread)));
}
let frame = Frame { page: visible, page_rect, rect };
let unseen = prune_unseen(&frame, &mut doc);
if unseen > 0 {
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
match frame {
Some(frame) => {
let unseen = prune_unseen(&frame, &mut doc);
if unseen > 0 {
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
}
notes.extend(compare(&frame, &doc)?);
}
None => unmark(&mut doc.layers, true, &mut vec![]),
}
notes.extend(compare(&frame, &doc)?);
Ok((doc, notes, rect))
Ok((doc, notes))
}
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
@ -765,9 +777,9 @@ fn compare(frame: &Frame<'_>, layered: &Document) -> Result<Option<String>, Stri
/// has one that can't be used, `visible` with a warning that says why.
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
let Some(data) = ai::eps_data(ps) else { return visible };
let result = data.and_then(|data| build(&data, &visible.document, Page::Art));
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art));
match result {
Ok((document, mut warnings, _)) => {
Ok((document, mut warnings)) => {
for w in &visible.warnings {
if !warnings.contains(w) {
warnings.push(w.clone());
@ -792,8 +804,8 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
return (visible, warnings);
}
match build(&data, &visible, Page::Artboard) {
Ok((done, notes, _)) => {
match build(&data, Some(&visible), Page::Artboard) {
Ok((done, notes)) => {
let mut all = warnings;
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
(done, all)
@ -806,6 +818,13 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (
}
}
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
let data = ai::decode_private(private)?;
build(&data, None, Page::Artboard)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -521,3 +521,24 @@ fn a_dictionary_left_open_at_the_end_of_a_layer_is_a_damaged_file() {
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| crate::is_loss(w)), "{:?}", r.warnings);
}
#[test]
fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
// Point type from the text document, shown or not; a story it can't read is left out, with a
// note that is a loss.
let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Area\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
// Story 1 in frame 1, a frame that says more than a matrix.
let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap();
doc.replace_range(at..at + 23, "/1 << /0 [ << /0 1 >> ]");
let at = doc.rfind("/2 << /2 [ 1 0 0 1 0 0 ] >>").unwrap();
doc.replace_range(at..at + 27, "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &(text_object(0) + &text_object(1)))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(names(&d), ["Back", "Words"], "{notes:?}");
let texts: Vec<String> = text_of(&d.layers[1]).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there"], "{notes:?}");
let left = notes.iter().find(|n| n.contains("left out")).unwrap();
assert!(left.starts_with("1 ") && crate::is_loss(left), "{notes:?}");
assert!(!notes.iter().any(|n| n.contains("order the page paints")), "{notes:?}");
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
}

View File

@ -34,7 +34,7 @@ mod ps;
mod scene;
mod tiff;
pub use import::{Imported, family_style, import, is_loss, layered_ai};
pub use import::{Imported, ai_alone, family_style, import, is_loss, layered_ai};
pub use print::{PrintJob, PrintPage, print};
use vectorcraft_doc::Document;

View File

@ -3676,6 +3676,7 @@ axis Both Ambos
@msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer
@msg an image whose data is cut short was left out se omitió una imagen con datos incompletos
@msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este archivo se guardó sin compatibilidad con PDF y sus datos de edición no se pueden leer ({why})
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servicios

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3673,6 +3673,7 @@ axis Both Les deux
@msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée
@msg an image whose data is cut short was left out une image aux données tronquées a été ignorée
@msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ce fichier a été enregistré sans compatibilité PDF et ses données d’édition sont illisibles ({why})
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Services

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3676,6 +3676,7 @@ axis Both Entrambi
@msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa
@msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa
@msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) questo file è stato salvato senza compatibilità PDF e i suoi dati di modifica non si possono leggere ({why})
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servizi

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3672,6 +3672,7 @@ axis Both Оба
@msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено
@msg an image whose data is cut short was left out изображение с обрезанными данными пропущено
@msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) этот файл сохранён без совместимости с PDF, и его данные редактирования не удаётся прочитать ({why})
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Службы

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -436,7 +436,9 @@ CMYK too.
```
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
reader (see EPS and PostScript import); an `.ai` whose PDF part is only a placeholder page says it can't be opened.
reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type), and
what can't be is left out with a warning; without editing data it says it can't be opened.
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,