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:?}"); +}