From 4f5d270ae922a7475f1117099862f762d48de541 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:54:31 +0300 Subject: [PATCH] .ai import: an empty page with skipped content isn't empty; outlines asked for are given Two follow-ups to the empty-page fallback: - A page counts as empty only when nothing on it was skipped: its only notes are the page's own (art off the artboards, art past the page). A page whose only content couldn't be read, such as an image that can't be decoded, keeps its notes and goes through the usual check against the editing data, instead of being replaced by it unchecked. - With textAs: outlines, the document read from the editing data alone has its type outlined. The usual path takes outlines from the page, but an empty page (or a text-only placeholder, #1387) has none to give. --- crates/engine/src/cmd/fileio/load.rs | 36 ++++++++++++- .../engine/src/cmd/fileio/tests_aiimport.rs | 51 +++++++++++++++++++ docs/file-format-parity.md | 1 + 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 2c0c7eb73..41503e062 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -197,6 +197,34 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option bool { + w == vectorcraft_pdf::OFF_ARTBOARD_NOTE || w == vectorcraft_pdf::PAST_PAGE_NOTE +} + +/// The document read from the editing data alone with its type as outlines when `opts` asks for +/// them (`textAs: outlines`): the page that would have them has none to give. +fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: &LoadOptions) -> (Document, Vec) { + if opts.text_as != vectorcraft_pdf::TextAs::Outlines { + return (doc, warnings); + } + let mut texts = Vec::new(); + doc.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) { + texts.push(n.clone()); + } + }); + for t in texts { + let Some(o) = vectorcraft_render::effects::outline_text(&t) else { continue }; + let o = Node { visible: t.visible, locked: t.locked, name: t.name.clone(), ..doc.reid(&o) }; + if let Some(slot) = doc.node_mut(t.id) { + *slot = o; + } + } + (doc, warnings) +} + /// Does `doc` hold no art: only layers, with nothing in them? fn draws_nothing(doc: &Document) -> bool { let mut art = false; @@ -282,10 +310,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // saved without its PDF part (#1387: else the page, which is the art). So too // pages that draw nothing at all: there is no page art to check the layers // against, and the empty page would lose all of it. - let alone = (r.only_text || draws_nothing(&r.document)) + // A page is empty only when nothing on it was skipped (an image that can't be + // decoded draws nothing either): its notes are at most the page's own. + let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w)); + let alone = (r.only_text || empty) .then(|| from_editing_data_alone(bytes, opts)) .flatten() - .and_then(|read| read.ok()); + .and_then(|read| read.ok()) + .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)); let (doc, warnings, read) = match alone { Some((doc, warnings)) => (doc, warnings, true), None => through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit), diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index acf7d43b1..ea72dba53 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -576,6 +576,57 @@ fn an_ai_file_whose_pdf_part_draws_nothing_opens_from_its_editing_data() { assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); } +/// A page that draws nothing only because what it holds couldn't be read (an image that can't be +/// decoded) isn't an empty page: its notes stay, and the editing data isn't taken in its place +/// unchecked. +#[test] +fn a_page_whose_content_was_skipped_is_not_taken_for_an_empty_one() { + let broken = "q 100 0 0 50 0 0 cm BI /W 2 /H 2 /CS /RGB /BPC 8 /F /DCT ID notajpeg EI Q"; + let mut s = Session::new(); + let r = open(&mut s, "broken.ai", &ai::ai(&compressed(&ai::sample_data()), broken), json!({})).unwrap(); + let warnings = r["warnings"].to_string(); + assert_ne!(r["warnings"], json!([]), "the skipped image is told: {r}"); + assert!(!warnings.contains("saved without PDF compatibility"), "{r}"); +} + +/// Type asked for as outlines on a page that draws nothing: the editing data's type, outlined (the +/// page has none to give). +#[test] +fn an_empty_page_with_type_as_outlines_outlines_the_editing_datas_type() { + // One left-aligned story, "Hi", 14 pt Helvetica, anchored at the canvas centre (the template + // box's centre), as the editing data's text document gives it. + let story = "<< /0 << /0 (Hi\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 3 >> ] >> \ + /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 3 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8191.5 8191.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \ + << /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 3 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>"; + let document = format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n/1 << /1 [ {story} ] /2 << /1 12.0 >> >>\n" + ); + let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n2 /TextAntialiasing ,\n;\n"; + let data = format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\ + %AI5_NumLayers: 1\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", + ai::layer("Art", text), + lines.join("\n") + ); + let file = ai::ai(&compressed(data.as_bytes()), ""); + let texts = |s: &Session| { + let mut n = 0; + s.doc().unwrap().doc.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Text(_)))); + n + }; + let mut s = Session::new(); + let r = open(&mut s, "typed.ai", &file, json!({})).unwrap(); + assert_eq!(texts(&s), 1, "by default the editing data's type is type: {r}"); + let r = open(&mut s, "typed.ai", &file, json!({"textAs": "outlines"})).unwrap(); + assert_eq!(texts(&s), 0, "no type left: {r}"); + let mut paths = 0; + s.doc().unwrap().doc.walk(|c| paths += usize::from(matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); + assert!(paths > 0, "the outlines are there: {r}"); + assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers"); +} + #[test] fn editing_data_false_opens_only_what_the_page_prints() { let mut s = Session::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a24b49a79..e7f7d8e1a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -137,6 +137,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 2026-10-30 | minor | SVG export: axis-aligned rectangles are written as ``, circles and ellipses drawn with four standard arcs as ``/``, straight lines as `` and other straight-segment paths as ``/``, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `` (#4652); scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file written as an object's copy on `%_` lines is read: its `%AI5_BeginPlace` and `%AI5_EndPlace` markers there were dropped, so the `~` that ends it was an unknown operator and the whole file came in as its PDF part; its preview's header, on a `%_` line too, is read. Own-fixture test in `import::ai::tests`; scores unchanged | | 2026-10-30 | minor | PDF export: an opaque gradient mesh is written as one PDF Type 6 (Coons patch) shading (ISO 32000-1, 8.7.4.5.7) filling its outline, so viewers blend its colours smoothly instead of showing the bands and seams of the 8 × 8 flat quads a patch it was cut into; a file of 65,558 small meshes exports in 14 s instead of 56 s, at half the size. A mesh with see-through points is still written as quads. Own-fixture tests in `tests::mesh_export`; scores unchanged | +| 2026-10-30 | minor | `.ai` import: a PDF part counts as an empty page only when nothing on it was skipped (an image that can't be decoded draws nothing too), so such a page keeps its notes and is checked against the editing data as before; with `textAs: "outlines"`, a file read from its editing data alone (an empty page, or the text-only placeholder of #1387) has its type outlined. Tests in `tests_aiimport.rs`; scores and hours unchanged | | 2026-10-30 | minor | `.ai` import: a file whose PDF part is an empty page opens from its editing data where that reads, instead of opening blank; scores unchanged | | 2026-10-30 | minor | `.ai` import: a file whose PDF part's pages are cropped to its art rather than its artboards no longer comes in as that PDF part (each page a copy of all the art) but from its layers, with its artboards, where they draw as the page does; own-fixture test in `tests_aiimport`; scores unchanged | | 2026-10-29 | minor | `.ai` and Illustrator EPS import: the editing data is read up to an eighth of installed memory, from 512 MiB to 2 GiB on 64-bit builds (it was 512 MiB on every machine), so a layered file whose embedded rasters unpack past 512 MiB keeps its layers, groups and text on a machine with 8 GiB or more instead of opening as its page or PDF part (#3510, #4628). Measured: 1.1 GB of editing data peaked 1.1 GB above the start; data past the limit is still refused (a 2 MB file unpacking to 2.2 GB stops in under a second). 32-bit and web builds keep 256 MiB. Own-fixture tests `import::ai::tests::editing_data_past_512_mib_is_read_where_memory_allows`, `the_editing_data_limit_follows_installed_memory`; scores and hours unchanged |