Merge pull request #4729 from mockydevs/fix/ai-import-review-followups

.ai import: outlines from the editing data keep their look and threads; mesh corners with non-numbers are unread
This commit is contained in:
BFlat
2026-10-31 00:51:49 +07:00
committed by GitHub
6 changed files with 155 additions and 24 deletions
+13 -4
View File
@@ -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<String>, opts: &LoadOptions) -> (Document, Vec<String>) {
/// 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<String>, opts: &LoadOptions) -> (Document, Vec<String>) {
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<String>, 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<Loaded>
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),
+113 -17
View File
@@ -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<u8> {
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();
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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<Node> {
pub(crate) fn outlined_text(n: &Node) -> Option<Node> {
effects::outline_text(n).map(|g| Node { knockout: Knockout::Off, ..carry(n, g) })
}
+26 -1
View File
@@ -186,7 +186,9 @@ impl MeshReader {
"N" => {
if let Some(V::Arr(a)) = value {
if self.corners.len() < 4 {
let n: Vec<f64> = 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<f64> = a.iter().take(64).map(V::num).collect::<Option<_>>().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<V> = 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() {
+1
View File
@@ -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 `<rect>`, circles and ellipses drawn with four standard arcs as `<circle>`/`<ellipse>`, straight lines as `<line>` and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `<path>` (#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 |