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:
parent
008b5e6d6c
commit
4b9f9b9bc2
File diff suppressed because one or more lines are too long
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue