From 7f5d8a3db1dc61b565a6955df1b60a22fea64fa6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 19:34:49 +0700 Subject: [PATCH] Fix #1387: a .ai whose PDF part is only text opens from it --- crates/engine/src/cmd/fileio/load.rs | 7 ++- crates/engine/src/cmd/fileio/pdfimport.rs | 7 ++- .../engine/src/cmd/fileio/tests_aiimport.rs | 46 +++++++++++++++++++ crates/pdf/src/import.rs | 14 +++--- crates/pdf/src/lib.rs | 3 ++ crates/pdf/src/pages.rs | 8 ++++ crates/testkit/src/ai.rs | 16 +++++-- docs/file-format-parity.md | 5 +- 8 files changed, 88 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 09afbb74..1ff18eee 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -222,7 +222,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result .flatten() .map(|e| (e.intact, move || Some(e.data))); restore_or_import(editing, || match super::pdfimport::import(bytes, opts) { - Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)), + Ok(r) => { + // Pages of text alone: the editing copy alone where it reads, as for a file + // saved without its PDF part (#1387: else the page, which is the art). + let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok()); + Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.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)) diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 1ece0eda..466e14c9 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -130,10 +130,9 @@ fn choice(cmd: &str, key: &str, id: Option<&str>) -> Result .ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", ")))) } -/// Import the pages `o` pick → the document and import notes. -pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec)> { - let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?; - Ok((r.document, r.warnings)) +/// Import the pages `o` pick → the document, import notes and what else the import tells. +pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result { + vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err) } /// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 6012c27e..d885215c 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -154,6 +154,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() { assert!(e.contains("placeholder"), "{e}"); } +/// A page of text alone, as text-only art has in its PDF part: no path is painted. +const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; + +/// The kinds of what the first layer holds: type (or its outlines, without the font) and others. +fn text_and_others(s: &Session) -> (usize, usize) { + let art = s.doc().unwrap().doc.layers[0].children().unwrap(); + let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("")).count(); + (text, art.len() - text) +} + +#[test] +fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() { + // #1387. The page states where its art is (an art, trim or bleed box), which the notice + // page of a file saved without its PDF part has no art to do. + let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0"; + for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] { + let mut s = Session::new(); + let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap(); + // Then a note on the font, where the machine lacks it. + assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}"); + assert_eq!(text_and_others(&s), (1, 0), "{boxed}"); + // The PDF part alone, as asked. + open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap(); + assert_eq!(text_and_others(&s), (1, 0), "{boxed}"); + } + // The same page without a box is the notice: the file still says why it can't open. + let mut s = Session::new(); + let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string(); + assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}"); + let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string(); + assert!(e.contains("placeholder"), "{e}"); + // Placed, it is read as its page too. + let r = s.execute("file.new", &json!({})).and_then(|_| { + let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]")); + s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false})) + }); + assert!(r.is_ok(), "{r:?}"); +} + +#[test] +fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() { + let mut s = Session::new(); + open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap(); + check_sample(&s); +} + #[test] fn editing_data_false_opens_only_what_the_page_prints() { let mut s = Session::new(); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 998b45ca..774d514e 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -196,8 +196,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = vec![]; let mut group_art: HashMap>> = HashMap::new(); let mut nesting = Nesting::new(); @@ -266,7 +267,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); - placeholder &= ai && only_text(&children); + only_text &= ai && is_only_text(&children); + art_boxes |= crate::pages::states_art(page); let mut right = ab.x1; if opts.crop == CropTo::Bounding && let Some(art) = vectorcraft_doc::live::nodes_bounds(&children) @@ -308,7 +310,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result, Vec>)>, page: Rect) } /// Is this art text and nothing else (in groups and clips), with some text? -fn only_text(nodes: &[Arc]) -> bool { +fn is_only_text(nodes: &[Arc]) -> bool { fn walk(nodes: &[Arc], text: &mut bool) -> bool { nodes.iter().all(|n| match &n.kind { NodeKind::Path { clipping: true, .. } => true, diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b45e084d..287ad0e5 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -163,6 +163,9 @@ pub struct ImportReport { pub warnings: Vec, /// The native document the PDF carries (written with Preserve Editing), if any. pub native: Option, + /// Every page shows only text, over an editor's private data: that data, where it reads, + /// is the file's art (a file saved without its PDF part shows a notice there instead). + pub only_text: bool, } /// Result of an export with non-fatal warnings (features approximated or dropped). diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index a23b2630..5a98c40c 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -197,6 +197,14 @@ pub(crate) fn has_private_data(page: &Page<'_>) -> bool { piece_info_has(page, &[b"AIPrivateData"]) } +/// Does `page` state where its art is: an art, trim or bleed box (ISO 32000-1 §14.11.2, the +/// extent of the page's meaningful content and of the finished page)? A page written from an +/// artboard has them; the notice a file saved without its PDF part shows instead of its art is +/// the same page whatever the document, with no art to state the place of. +pub(crate) fn states_art(page: &Page<'_>) -> bool { + [&b"ArtBox"[..], b"TrimBox", b"BleedBox"].iter().any(|k| page.raw().get::<[f32; 4]>(*k).is_some()) +} + /// Does `page`'s `PieceInfo` hold a key starting with one of `prefixes`, up to four dictionaries in? fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool { fn walk(d: &Dict<'_>, prefixes: &[&[u8]], depth: u32) -> bool { diff --git a/crates/testkit/src/ai.rs b/crates/testkit/src/ai.rs index e8daf194..a999a13e 100644 --- a/crates/testkit/src/ai.rs +++ b/crates/testkit/src/ai.rs @@ -104,7 +104,12 @@ pub fn eps(data: &[u8], page: &str) -> Vec { /// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in /// two streams. pub fn ai(private: &[u8], page: &str) -> Vec { - with_private(private, page, "AIPrivateData") + ai_with(private, page, "") +} + +/// [`ai`] with more entries in the page dictionary (`/ArtBox [0 0 200 100]`). +pub fn ai_with(private: &[u8], page: &str, entries: &str) -> Vec { + with_private(private, page, "AIPrivateData", entries) } /// A PDF saved with Illustrator's editing data: [`ai`]'s file with the two streams under @@ -112,11 +117,12 @@ pub fn ai(private: &[u8], page: &str) -> Vec { /// #868, and nothing else of that file is used; ExifTool's PDF tag table lists them too /// (). pub fn pdf(private: &[u8], page: &str) -> Vec { - with_private(private, page, "AIPDFPrivateData") + with_private(private, page, "AIPDFPrivateData", "") } -/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2`. -fn with_private(private: &[u8], page: &str, key: &str) -> Vec { +/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2` and +/// `entries` more in the page dictionary. +fn with_private(private: &[u8], page: &str, key: &str, entries: &str) -> Vec { let (first, second) = private.split_at(private.len() / 2); let stream = |part: &[u8]| { let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes(); @@ -128,7 +134,7 @@ fn with_private(private: &[u8], page: &str, key: &str) -> Vec { b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \ + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] {entries} /Contents 4 0 R \ /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \ /PieceInfo << /{PIECE} << /Private << /{key}1 5 0 R /{key}2 6 0 R /NumBlock 2 >> >> >> >>" ) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1ea8f6f5..4aa97bd1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public @@ -47,7 +47,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -83,6 +83,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A `.ai` whose PDF part draws only text opens from that part when its editing data can't be read, instead of being refused as saved without PDF compatibility: a page that states an art, trim or bleed box is art, not the placeholder (#1387); scores unchanged | | 2026-10-12 | minor | PDF and `.ai` import, and placed PDFs, keep overprint: the graphics state's `/OP` and `/op` (ISO 32000-1 §8.6.7) become the overprint of fills, strokes and type, so a spot colour set to overprint still does after export; `/OPM 0` is reported in a note (#1388); scores unchanged | | 2026-10-12 | minor | `.ai` editing data: a gradient with middle stops runs its whole ramp (#1390); type of objects with an appearance, of hidden layers and on later artboards goes to its own text object (#1391); area type in a frame too short for the fallback font shows its first line (#1392); scores unchanged | | 2026-10-12 | minor | `.ai` editing data: placed (linked) files come in as linked images showing the preview the file keeps, so a file with them opens through its layers, with its guides (#1264); scores unchanged |