.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.
This commit is contained in:
Don Omondi
2026-10-30 23:55:32 +07:00
committed by bflatastic
parent 1d58ea947d
commit 66c797a59b
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) })
}