Merge pull request #1391 from storytold/fix/1264-ai-labels

.ai import: type goes to its own text object across looks, hidden layers and artboards (follow-up to #1264)
This commit is contained in:
BFlat 2026-10-12 18:51:55 +07:00 committed by GitHub
commit 46131bd091
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 129 additions and 3 deletions

View File

@ -1738,6 +1738,7 @@ fn editing_text() -> String {
"1 Xw".into(),
"u".into(),
"/AI11Text :\n0 /FreeUndo ,\n;".into(),
"/AI11Text :\n1 /FreeUndo ,\n;\n1 (Style) XW".into(),
"U".into(),
"0 Xw".into(),
"1 0 0 0 1 0 Bg".into(),

View File

@ -40,6 +40,10 @@ pub(crate) fn text_frames(data: &[u8]) -> HashMap<(u32, usize), BezPath> {
continue;
}
let Some(head) = seg.get(..art) else { continue };
// The type in a drawn look is a story of another text document (see `Reader::text_slot`).
if number_before(head, b"/FreeUndo") == Some(1.0) {
continue;
}
let (Some(story), frame) = (number_before(head, b"/StoryIndex"), number_before(head, b"/FrameIndex").unwrap_or(0.0)) else { continue };
if !(0.0..f64::from(u32::MAX)).contains(&story) || !(0.0..1e6).contains(&frame) {
continue;
@ -123,6 +127,12 @@ mod tests {
assert_eq!(f.len(), 2, "point type has no frame: {f:?}");
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(10.0, 20.0, 110.0, 80.0));
assert_eq!(f[&(60, 1)].elements().len(), 2, "a curve, led by %_");
// The type in a drawn look counts its stories in another text document: its frame isn't
// that of the text object which has the same number in this one.
let look = String::from_utf8_lossy(doc).replacen("0 /FreeUndo", "1 /FreeUndo", 1).replacen("10 20 m", "500 20 m", 1);
let own = "/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r49 /StoryIndex ,\r/Art :\r1 2 m\r3 2 L\r3 4 L\rN\r;\r/ConfiningPath ,\r;\r";
let f = text_frames(format!("{look}{own}").as_bytes());
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0));
assert!(text_frames(b"/AI11Text : garbage").is_empty());
}
}

View File

@ -1023,6 +1023,12 @@ impl<'a> Reader<'a> {
if self.pattern.is_some() {
self.unreadable("type in a pattern");
}
// `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its
// story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its
// stories on its own; the object itself follows the look, on `%_` lines, and is the type.
if o.nums("FreeUndo").first() == Some(&1.0) {
return Ok(());
}
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
let gs = self.gs.clone();
@ -1288,9 +1294,20 @@ impl<'a> Reader<'a> {
if !self.hidden_ids.contains(&object.id) || self.hidden_ids.contains(&look.id) || !matches!(look.kind, NodeKind::Group { clip: false, .. }) {
return;
}
let is_text = slot_of(object.name.as_deref()).is_some();
let Some(object) = f.children.pop() else { return };
let Some(look) = f.children.last_mut() else { return };
strip(look, &self.hidden_ids);
if is_text {
// A text object stays the type it is: over what else its look draws, or in place of a
// look that was only its type.
if look.children().is_some_and(|c| c.is_empty()) {
*look = object;
} else {
f.children.push(object);
}
return;
}
look.name = object.name.clone().or(look.name.take());
look.visible = object.visible;
look.locked = object.locked;

View File

@ -476,6 +476,20 @@ fn text_objects_are_slots_named_after_their_story() {
let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1))));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(0)), Some(Some(1))]);
// A text object with an appearance: its drawn look (a group whose type is a story of the
// undo-free text document, which counts its own), then the object on `%_` lines, which is
// the type.
let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n0 0 Xd\n6 () XW\n";
let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect();
let plain = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n{plain}")));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(7)), Some(Some(0))], "the look's story 0 isn't the text document's");
// What else the look draws stays, under the type.
let look = look.replace("U\n", &format!("{RED}{}f\nU\n", rect_path(0.0, 0.0, 10.0, 10.0)));
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n")));
let a = art(&r.doc);
assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7))));
}
#[test]

View File

@ -453,10 +453,11 @@ fn plan(
Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect())
})
.collect();
// The page's objects that are where a shown slot's lines are, nearest first.
// The page's objects that are where a slot's lines are, nearest first. A slot that doesn't show
// has them too: a file's page may draw what its layers have hidden since.
let mut pairs: Vec<(f64, usize, usize)> = vec![];
for (k, info) in infos.iter().enumerate() {
let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue };
for (k, _) in infos.iter().enumerate() {
let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue };
for (j, object) in shown.iter().enumerate() {
if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) {
pairs.push((d, k, j));
@ -472,6 +473,8 @@ fn plan(
}
// Type the text document makes, shown or not: the page's pieces of it aren't needed.
let mut content: Vec<Content> = infos.iter().map(|_| Content::Empty).collect();
// Where the made type that doesn't show lies, by slot.
let mut hidden_at: Vec<(usize, Rect)> = vec![];
if let Some(t) = template {
for (k, info) in infos.iter().enumerate() {
let Some(Some(story)) = stories.get(k) else { continue };
@ -493,6 +496,9 @@ fn plan(
TextKind::Point => {}
}
}
if let (false, Some(r)) = (info.shown, region) {
hidden_at.push((k, r));
}
if let Some(c) = content.get_mut(k) {
*c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]);
}
@ -506,9 +512,26 @@ fn plan(
if !trusted {
content.iter_mut().for_each(|c| *c = Content::Empty);
}
// Is `object` the type of a text object that doesn't show, as the page draws it: in its frame,
// with its words? (A copied object's lines are where its original's are, so they don't say.)
let hidden_type = |object: &[Arc<Node>]| {
let Some(r) = extent(object) else { return false };
let words: Vec<String> =
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect();
hidden_at.iter().any(|(k, place)| {
place.intersect(r).area() >= 0.5 * r.area().max(1e-9)
&& lines
.get(*k)
.and_then(Option::as_ref)
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
})
};
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
for (j, object) in shown.iter().enumerate() {
match owner.get(j).copied().flatten().filter(|_| trusted) {
// The type of a text object the layers hide: the layers have it, hidden, so the page's
// isn't another text object's.
Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {}
Some(k) => match content.get_mut(k) {
Some(Content::Made(_, _, seen, _)) => *seen = true,
Some(Content::Page(p)) => p.extend(object.iter().cloned()),
@ -526,6 +549,7 @@ fn plan(
}
}
}
None if trusted && hidden_type(object) => {}
None => spare.push(object.clone()),
}
}
@ -857,7 +881,22 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
text_objects(c, l.visible, &mut shown, &mut hidden);
}
}
// A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each
// goes where the layers have that artboard.
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
let same =
|(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5;
let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards);
for object in shown.iter_mut().chain(hidden.iter_mut()) {
// The page with most of it, the first for what is on none.
let on = extent(object).and_then(|r| {
let inside = |p: &Rect| p.intersect(r).area().max(0.0);
pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1)))
});
let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) {
Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()),
None => shift,
};
for n in object.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}

View File

@ -491,6 +491,20 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
}
#[test]
fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() {
// A file's page may draw what its layers have hidden since: story 1's type is on the page at
// (58.5, 30), and its text object is on a layer that doesn't show.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Extra\r", 0, (8200.0, 8211.5), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(layer("Art", true, &text_object(0)) + &layer("Spare", false, &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings);
let texts = |l: usize| text_of(&r.document.layers[l]).iter().map(|t| t.plain_text()).collect::<Vec<_>>();
assert_eq!((texts(0), texts(1)), (vec!["Hi there".to_string()], vec!["Extra".to_string()]), "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
}
#[test]
fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() {
// The file writes some stories twice (plain, then as comments) and others only as comments.
@ -587,6 +601,37 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
}
/// A `.ai`'s PDF part has a page for each artboard, in a row: the type on a later page goes to
/// its text object where the layers have that artboard, not by the first page's place.
#[test]
fn type_on_a_later_artboard_of_an_ai_file_goes_to_its_text_object() {
// Two 100-point artboards, the second 50 below the first; the type is 50 down the second
// (art space: (58.5, -100); the canvas centre 8191.5 is the template box's centre, (50, 50)).
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8341.5), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(boards.to_string() + &layer("Art", true, &(square(10, 10) + &text_object(0)))), &doc);
// The PDF part: the second page 20 points right of the first, the type 50 down it.
let mut pdf = pdf_part(10.0, 86.0);
let mut page = pdf.artboards[0].clone();
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
pdf.artboards.push(page);
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(178.5, 50.0), "Hi there", style);
let (id, layer_id) = (pdf.alloc_id(), pdf.layers[0].id);
pdf.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap();
let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false);
assert!(read, "{w:?}");
assert_eq!(d.artboards[1].rect, vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0));
let texts = text_of(&d.layers[0]);
assert_eq!(texts.len(), 1, "{w:?}");
let [.., e, f] = texts[0].xf.as_coeffs();
assert!((e - 58.5).abs() < 1e-6 && (f - 200.0).abs() < 1e-6, "{:?}", texts[0].xf);
assert!(!w.iter().any(|w| w.contains("with another's") || w.contains("doesn't draw")), "{w:?}");
}
/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its
/// PDF part, which has only the top-level layers (#1025).
#[test]