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 |