From 66c797a59b11057151c8f707dfb98e609f68951f Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:53:03 +0300 Subject: [PATCH] .ai import: outlines from the editing data keep their look and threads; mesh corners with non-numbers are unread Follow-ups to the review of #4697 and #4698: - With textAs: outlines, a .ai saved without PDF compatibility, read from its editing data alone, has its type outlined too, as an empty page or a text-only placeholder already did. - Threaded type read from the editing data holds its story in its first frame: it now flows through its frames before it is outlined, so each frame's outlines show the text it holds, and the outlined frames leave their threads. - Each outline group keeps its type's opacity, blend mode, isolation, opacity mask and effects (the same carry-over the flattener uses). - A gradient-mesh corner (/N) holding anything but numbers is unread, instead of being read with the numbers after it shifted into its colour and opacity. --- crates/engine/src/cmd/fileio/load.rs | 17 ++- .../engine/src/cmd/fileio/tests_aiimport.rs | 130 +++++++++++++++--- crates/engine/src/cmd/flatten.rs | 2 +- crates/engine/src/cmd/flatten/analysis.rs | 2 +- crates/eps/src/import/ai/mesh.rs | 27 +++- docs/file-format-parity.md | 1 + 6 files changed, 155 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 41503e062..c2e58e39e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -204,11 +204,18 @@ fn is_page_note(w: &str) -> bool { } /// 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) { +/// them (`textAs: outlines`): the page that would have them has none to give. Threaded type first +/// flows through its frames (the editing data gives a story in its first frame), and the outlined +/// frames leave their threads. Each outline group keeps its type's transparency, opacity mask and +/// effects. +pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: &LoadOptions) -> (Document, Vec) { if opts.text_as != vectorcraft_pdf::TextAs::Outlines { return (doc, warnings); } + if !doc.text_threads.is_empty() { + crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); + } + let threaded = crate::cmd::threads::frames(&doc); let mut texts = Vec::new(); doc.walk(|n| { if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) { @@ -216,12 +223,13 @@ fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: &LoadOption } }); 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) }; + let Some(o) = crate::cmd::flatten::outlined_text(&t) else { continue }; + let o = Node { visible: t.visible, locked: t.locked, ..doc.reid(&o) }; if let Some(slot) = doc.node_mut(t.id) { *slot = o; } } + crate::cmd::threads::leave_threads(&mut doc, &threaded); (doc, warnings) } @@ -331,6 +339,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let whole = LoadOptions { pages: None, ..opts.clone() }; from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }) .unwrap_or(Err(e)) + .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)) .inspect(|_| editing_data = true) } Err(e) => Err(e), diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index ea72dba53..edccacd50 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -6,7 +6,7 @@ use std::io::Write as _; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Node, NodeId, NodeKind}; use vectorcraft_testkit::ai; use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; @@ -589,12 +589,10 @@ fn a_page_whose_content_was_skipped_is_not_taken_for_an_empty_one() { 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. +/// Editing data with one layer, "Art", holding 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; compressed. +fn typed_data() -> Vec { 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 [ \ @@ -610,23 +608,121 @@ fn an_empty_page_with_type_as_outlines_outlines_the_editing_datas_type() { 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 - }; + compressed(data.as_bytes()) +} + +/// How many text objects and how many paths the open document holds. +fn texts_and_paths(s: &Session) -> (usize, usize) { + let (mut texts, mut paths) = (0, 0); + s.doc().unwrap().doc.walk(|c| { + texts += usize::from(matches!(c.kind, NodeKind::Text(_))); + paths += usize::from(matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); + }); + (texts, paths) +} + +/// 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() { + let file = ai::ai(&typed_data(), ""); 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}"); + assert_eq!(texts_and_paths(&s).0, 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 { .. }))); + let (texts, paths) = texts_and_paths(&s); + assert_eq!(texts, 0, "no type left: {r}"); assert!(paths > 0, "the outlines are there: {r}"); assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers"); } +/// Type asked for as outlines in a `.ai` saved without PDF compatibility (its page only a +/// placeholder): the editing data's type, outlined, as for an empty page. +#[test] +fn a_file_saved_without_its_pdf_part_with_type_as_outlines_outlines_its_type() { + let file = ai::ai(&typed_data(), PLACEHOLDER); + let mut s = Session::new(); + let r = open(&mut s, "typed.ai", &file, json!({})).unwrap(); + assert_eq!(texts_and_paths(&s).0, 1, "by default the editing data's type is type: {r}"); + let r = open(&mut s, "typed.ai", &file, json!({"textAs": "outlines"})).unwrap(); + let (texts, paths) = texts_and_paths(&s); + assert_eq!(texts, 0, "no type left: {r}"); + assert!(paths > 0, "the outlines are there: {r}"); + assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers"); +} + +/// Type of the editing data outlined keeps its transparency, opacity mask and effects, and is +/// shown and locked as the type was. +#[test] +fn outlined_type_of_the_editing_data_keeps_its_transparency_and_effects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let mut doc = s.doc().unwrap().doc.as_ref().clone(); + let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; + { + let t = doc.node_mut(id).unwrap(); + t.opacity = 0.5; + t.blend = vectorcraft_color::BlendMode::Multiply; + t.isolate = true; + t.locked = true; + t.name = Some("Title".into()); + t.appearance.effects = vec![shadow.clone()]; + } + let opts = LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() }; + let (doc, _) = load::outlined_if_asked(doc, vec![], &opts); + let mut outlines = vec![]; + doc.walk(|n| { + if n.extras().type_outlines { + outlines.push(n.clone()); + } + }); + let [o] = &outlines[..] else { panic!("one group of outlines: {outlines:?}") }; + assert_eq!((o.opacity, o.blend, o.isolate), (0.5, vectorcraft_color::BlendMode::Multiply, true)); + assert_eq!(o.appearance.effects, [shadow]); + assert!(o.locked && o.visible); + assert_eq!(o.name.as_deref(), Some("Title")); +} + +/// Threaded type of the editing data, its story all in its first frame as the editing data gives +/// it, outlined: each frame's outlines show the text that flows into it. +#[test] +fn outlined_threaded_type_of_the_editing_data_shows_the_text_of_every_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4); + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + // As the editing data gives it: the first frame holds the story, the second nothing yet. + let mut doc = s.doc().unwrap().doc.as_ref().clone(); + let runs = |id: u64| match &doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("area type"), + }; + let (shown, flowed) = (runs(a), runs(b)); + let empty: Vec<_> = flowed.iter().take(1).map(|r| vectorcraft_doc::TextRun { text: String::new(), ..r.clone() }).collect(); + for (id, put) in [(a, [shown, flowed].concat()), (b, empty)] { + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.runs = put; + t.cached_bounds = None; + t.cached_baselines.clear(); + } + } + let opts = LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() }; + let (doc, _) = load::outlined_if_asked(doc, vec![], &opts); + let mut right = 0; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.geometric_bounds().is_some_and(|r| r.x0 >= 190.0) { + right += 1; + } + }); + assert!(right > 0, "the second frame's text is outlined where it flows"); + assert!(doc.text_threads.is_empty(), "outlined frames are in no thread: {:?}", doc.text_threads); +} + #[test] fn editing_data_false_opens_only_what_the_page_prints() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index c7ddd8bc5..47a190a43 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -48,7 +48,7 @@ mod options; mod presets; mod report; -pub(crate) use analysis::see_through_images; +pub(crate) use analysis::{outlined_text, see_through_images}; pub(crate) use apply::flatten_document; pub use commands::specs; pub use options::FlattenOptions; diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index cad3b9384..4bdbb8b72 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -51,7 +51,7 @@ impl Scratch { } /// Type as outlines painted like it ([`effects::outline_text`]), keeping its transparency. -pub(super) fn outlined_text(n: &Node) -> Option { +pub(crate) fn outlined_text(n: &Node) -> Option { effects::outline_text(n).map(|g| Node { knockout: Knockout::Off, ..carry(n, g) }) } diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 0b2438c7e..b33869a95 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -186,7 +186,9 @@ impl MeshReader { "N" => { if let Some(V::Arr(a)) = value { if self.corners.len() < 4 { - let n: Vec = a.iter().filter_map(V::num).take(64).collect(); + // A corner holding anything but numbers is unread: skipping it would shift + // the numbers after it into the colour or the opacity. + let n: Vec = a.iter().take(64).map(V::num).collect::>().unwrap_or_default(); let comps = self.space.components(); let color = comps.and_then(|k| self.space.color(n.get(..k)?)); let rest = comps.and_then(|k| n.get(k..)).map(<[f64]>::to_vec).unwrap_or_default(); @@ -901,6 +903,29 @@ mod tests { } } + /// A corner holding anything but numbers is unread: skipping it would shift the numbers after + /// it into its colour and opacity (two inks' tints `0.25 /bad 0.5`, read as `0.25 0.5` and no + /// opacity). + #[test] + fn a_corner_with_something_other_than_numbers_is_unread() { + let ink = |c: [f64; 4], name: &str| { + let mut v: Vec = c.iter().map(|x| V::Num(*x)).collect(); + v.extend([V::Name("DeviceCMYK".into()), V::Str(name.as_bytes().to_vec()), V::Name("Separation".into())]); + V::Arr(v) + }; + let channels = + V::Arr(vec![ink([0.0, 0.0, 1.0, 0.0], "Yellow"), ink([0.0, 1.0, 0.0, 0.0], "Magenta"), V::Num(2.0), V::Name("NChannel".into())]); + let mut reader = MeshReader::default(); + reader.entry(&[channels, V::Name("CS".into())], Affine::IDENTITY); + for (x, y) in [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)] { + let mut corner = vec![V::Num(0.25), V::Name("bad".into()), V::Num(0.5)]; + corner.extend([x, y, x, y, 1.0, x, y, 1.0].map(V::Num)); + reader.entry(&[V::Arr(corner), V::Name("N".into())], Affine::IDENTITY); + } + reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY); + assert!(reader.unread && reader.patches.is_empty(), "{:?}", reader.patches); + } + /// Meshes as large as files users sent (97 × 72 patches) are read, as one mesh. #[test] fn a_large_grid_is_read_as_one_mesh() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 48cd9701f..af662106c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -141,6 +141,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 with `textAs: "outlines"`, read from the editing data alone: a file saved without PDF compatibility has its type outlined too; threaded type flows through its frames before it is outlined, so each frame's outlines show the text it holds; and each outline group keeps its type's opacity, blending, opacity mask and effects. `.ai`/EPS import: a gradient-mesh corner holding anything but numbers is unread instead of read with the numbers after it shifted into its colour and opacity. Tests in `tests_aiimport.rs` and `import::ai::mesh::tests`; scores and hours 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 |