From 996111b179bb9ab87a2a9d2212298f930adfd52a Mon Sep 17 00:00:00 2001 From: Hiroshi Inukai Date: Sun, 11 Oct 2026 19:27:49 +0900 Subject: [PATCH] .ai import: copied area type and type on a path keep their own place Read through its editing data, a .ai put the copies of area type and type on a path on top of their original: the text document's frame of a copied object keeps the original's path (with a matrix of its own that isn't understood yet). The object's `/Art` path, which the layers' data keeps for each text object, is where the file places it: area type and type on a path now take it as their frame or path, by story and frame. On a file of cards made by copying one, the type laid out from the text document now lands on each card (515 text objects where its PDF part has them, against 467, many on one card, before). Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/frames.rs | 133 ++++++++++++++++++++++++++ crates/eps/src/import/ai/mod.rs | 2 + crates/eps/src/import/native.rs | 33 ++++++- crates/eps/src/import/tests_native.rs | 17 ++++ 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 crates/eps/src/import/ai/frames.rs diff --git a/crates/eps/src/import/ai/frames.rs b/crates/eps/src/import/ai/frames.rs new file mode 100644 index 000000000..243aab0fe --- /dev/null +++ b/crates/eps/src/import/ai/frames.rs @@ -0,0 +1,133 @@ +//! Where each text object of an editing copy is framed: the `/Art` path an `/AI11Text` object +//! carries (its area type's frame, or the path type on a path runs along), by the story and frame +//! the object shows. The text document's frame of a copied object keeps its original's path, so +//! read alone it puts every copy on the original; the object's own `/Art` path is where the file +//! places it, in the art space's path operators. +//! +//! An `/AI11Text` object is written as `/AI11Text :` … `n /FrameIndex ,` `n /StoryIndex ,` +//! `/Art :` (its path) `;` … `/ConfiningPath ,` (point type has no `/Art`), its lines plain or each +//! led by `%_`. + +use std::collections::HashMap; + +use kurbo::{BezPath, Point}; + +/// The most text objects read. +const MAX_OBJECTS: usize = 100_000; +/// How far past `/AI11Text :` an object's `/Art` path may end (bytes). +const MAX_OBJECT: usize = 256 << 10; +const OBJECT: &[u8] = b"/AI11Text :"; + +/// The `/Art` path of each text object of `data` (an editing copy's document text), in art space +/// (y up), by its story and frame. Objects without a readable path are left out. +pub(crate) fn text_frames(data: &[u8]) -> HashMap<(u32, usize), BezPath> { + let mut out = HashMap::new(); + let mut at = 0; + while let Some(i) = data.get(at..).and_then(|d| find(d, OBJECT)) { + let start = at + i; + at = start + OBJECT.len(); + if out.len() >= MAX_OBJECTS { + break; + } + let Some(seg) = data.get(start..(start + MAX_OBJECT).min(data.len())) else { break }; + // The object ends where the next one starts. + let seg = seg.get(OBJECT.len()..).and_then(|rest| find(rest, OBJECT)).and_then(|j| seg.get(..j + OBJECT.len())).unwrap_or(seg); + let (Some(art), Some(end)) = (find(seg, b"/Art :"), find(seg, b"/ConfiningPath")) else { continue }; + if end < art { + continue; + } + let Some(head) = seg.get(..art) else { 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; + } + if let Some(p) = seg.get(art + 6..end).and_then(path) { + out.entry((story as u32, frame as usize)).or_insert(p); + } + } + out +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +/// The words of `text`, without the `%_` that leads lines inside dictionaries. +fn words(text: &[u8]) -> impl Iterator { + text.split(|b| b.is_ascii_whitespace()).map(|w| w.strip_prefix(b"%_").unwrap_or(w)).filter(|w| !w.is_empty()) +} + +fn number(w: &[u8]) -> Option { + std::str::from_utf8(w).ok()?.parse::().ok().filter(|v| v.is_finite()) +} + +/// The number written just before `key` (`49 /StoryIndex`) in `head`. +fn number_before(head: &[u8], key: &[u8]) -> Option { + let k = find(head, key)?; + number(words(head.get(..k)?).last()?) +} + +/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`; any other +/// operator ends the numbers before it). +fn path(text: &[u8]) -> Option { + let mut bp = BezPath::new(); + let mut nums: Vec = vec![]; + let mut cur: Option = None; + for w in words(text) { + if let Some(v) = number(w) { + nums.push(v); + continue; + } + let p = |i: usize| -> Option { Some(Point::new(*nums.get(i)?, *nums.get(i + 1)?)) }; + let n = nums.len(); + match w { + b"m" if n >= 2 => { + let q = p(n - 2)?; + bp.move_to(q); + cur = Some(q); + } + b"l" | b"L" if n >= 2 && cur.is_some() => { + let q = p(n - 2)?; + bp.line_to(q); + cur = Some(q); + } + b"c" | b"C" if n >= 6 && cur.is_some() => { + let q = p(n - 2)?; + bp.curve_to(p(n - 6)?, p(n - 4)?, q); + cur = Some(q); + } + b"v" | b"V" if n >= 4 => { + let c0 = cur?; + let q = p(n - 2)?; + bp.curve_to(c0, p(n - 4)?, q); + cur = Some(q); + } + b"y" | b"Y" if n >= 4 && cur.is_some() => { + let q = p(n - 2)?; + bp.curve_to(p(n - 4)?, q, q); + cur = Some(q); + } + _ => {} + } + nums.clear(); + } + (bp.elements().len() >= 2).then_some(bp) +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn each_text_object_has_its_own_frame_by_story_and_frame() { + let doc = b"/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r49 /StoryIndex ,\r/Art :\rX=\r7 As\r10 20 m\r110 20 L\r110 80 L\rN\rX+\r;\r/ConfiningPath ,\r;\r\ + /AI11Text :\r%_0 /FreeUndo ,\r%_1 /FrameIndex ,\r%_60 /StoryIndex ,\r%_/Art :\r%_X=\r%_300 20 m\r%_340 20 350 30 360 40 c\r%_N\r%_;\r%_/ConfiningPath ,\r%_;\r\ + /AI11Text :\r1 /FreeUndo ,\r0 /FrameIndex ,\r33 /StoryIndex ,\r2 /TextAntialiasing ,\r;\r"; + let f = text_frames(doc); + 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 %_"); + assert!(text_frames(b"/AI11Text : garbage").is_empty()); + } +} diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index 49dc7b450..067046382 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -17,6 +17,7 @@ //! objects and points. Anything it doesn't read is an error: the caller opens the file the way it //! did before (the printed page, or the PDF), with a warning saying why. +mod frames; mod lex; mod obj; mod paint; @@ -27,6 +28,7 @@ use std::io::Read as _; use super::lex::find; use crate::ps; +pub(crate) use frames::text_frames; pub use read::{Structure, artboards, read}; pub(crate) use read::{slot_frame, slot_of}; diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 3fb1066d5..18107ece7 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -29,8 +29,8 @@ use std::borrow::Cow; use std::collections::BTreeSet; use std::sync::Arc; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind}; +use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2}; use super::Imported; use super::ai::{self, Structure, slot_of}; @@ -78,6 +78,8 @@ struct SlotInfo { frame: usize, /// It, its layer and the objects round it show. shown: bool, + /// Its own frame or path, where the file places it ([`ai::text_frames`]). + art_path: Option, } /// The text slots of the layers and objects, in order. @@ -85,7 +87,7 @@ fn collect_slots(nodes: &[Arc], shown: bool, out: &mut Vec) { for n in nodes { let shown = shown && n.visible; if let Some(story) = slot_of(n.name.as_deref()) { - out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown }); + out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None }); } else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { collect_slots(c, shown, out); } @@ -473,8 +475,24 @@ fn plan( if let Some(t) = template { for (k, info) in infos.iter().enumerate() { let Some(Some(story)) = stories.get(k) else { continue }; - let Some(node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; - let region = story.region(t, to_doc); + let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; + let mut region = story.region(t, to_doc); + // Area type and type on a path in the object's own frame: the text document's frame + // of a copied object keeps its original's path. + if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) { + let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + match &mut text.kind { + TextKind::Area { frame } => { + *frame = path.clone(); + region = path.bounds(); + } + TextKind::OnPath { path: on, .. } => { + *on = path.clone(); + region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); + } + TextKind::Point => {} + } + } if let Some(c) = content.get_mut(k) { *c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]); } @@ -860,6 +878,11 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) -> } let mut infos = vec![]; collect_slots(&doc.layers, true, &mut infos); + // Each text object's own frame or path, by its story and frame. + let frames = ai::text_frames(data); + for info in &mut infos { + info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(to_doc * p.clone()))); + } // Type asked for as outlines is the page's: the text objects that show can't be filled. if outlined && infos.iter().any(|i| i.shown) { return Err("its type is to come in as outlines, which only its page has".into()); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 6677839ea..d64450541 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -806,3 +806,20 @@ fn shown_type_the_page_draws_in_pieces_comes_whole_from_the_text_document() { assert_eq!(all, ["Letters"], "{:?}", r.warnings); assert!(!r.warnings.iter().any(|w| w.contains("page's type")), "{:?}", r.warnings); } + +#[test] +fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() { + // The text document's frame is the original's (art 10..60 × 20..40); this copy's own `/Art` + // path puts it at 30..80 × 50..70. + let area = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(10.0, 20.0, 60.0, 40.0)); + let doc = document_of(&[area], &[story("Copied\r", &[0], "/0 0", "/1 8.0")]); + let copy = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n30 50 m\n80 50 L\n80 70 L\n30 70 L\nN\n;\n/ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, copy)), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let vectorcraft_doc::TextKind::Area { frame } = &texts[0].kind else { panic!("area type: {:?}", texts[0].kind) }; + // Art y 50..70 is 30..50 down the 100 pt page. + let b = frame.bounds().unwrap(); + assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}"); +}