mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
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:
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user