From 48f0347351efa2a0c6dedb38b1c16e094292e29d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 01:30:46 -0800 Subject: [PATCH] .ai import: area type with linked areas opens as threaded frames In the legacy format, area type whose story flows through several linked areas has one text path (`Tp` ... `TP`) for each area, each with its own matrix and frame (version 7 specification, sections 15 and 15.5.3). The reader kept only the first frame, placed it with the text object's last valid matrix, and put the whole story in that one frame. Each linked area with a frame now opens as its own area type object, placed with its own matrix, and the frames are threaded in file order. The first frame holds the story: the characters after every `TP`, and the overflow text. When the file opens, the story is laid out again through the thread (section 15.1 lists the flow from area to area as final-form information). A linked area without a frame is left out. Area type with one area opens as before. The dictionary that follows a text object (its opacity mask, text wrap, name and other keys) went to its one object. Its opacity mask and text wrap now go on every frame, and its other keys on the first frame. Point type is read with its first text path's matrix, and type on a path with the matrix and start of the first text path that has a path. Area type with no frame at all opens as point type with its first text path's matrix. Before, these used the last valid matrix and, for type on a path, the last start. One text object reads at most 4,096 text paths. The number of frames times the story's character count is kept to 4,194,304, so a longer story keeps fewer frames. Frames past either limit are left out, with a warning. --- crates/engine/tests/import_fuzz.rs | 41 +++++ crates/eps/src/import/ai/legacy_text.rs | 154 +++++++++++++----- crates/eps/src/import/ai/read/document.rs | 4 +- crates/eps/src/import/ai/tests.rs | 182 ++++++++++++++++++++++ docs/file-format-parity.md | 3 + 5 files changed, 345 insertions(+), 39 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 2ee24372a..081c9ee4a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3753,6 +3753,47 @@ proptest! { } } +/// Area type in the version 7 form (§15.5.3) whose story flows through two linked areas: a small +/// one, and one a quarter turn round, each area's characters after its `TP`. +const LINKED_AREAS: &str = "1 To\n1 0 0 1 120 90 0 Tp\n120 60 m\n120 90 L\n180 90 L\n180 60 L\n120 60 L\nn\nTP\n0 Tr\n0 g\n/_Helvetica 9 Tf\n\ + (one two three four five six seven eight nine ten ) Tx\n0 1 -1 0 190 10 0 Tp\n130 10 m\n130 55 L\n190 55 L\n190 10 L\n130 10 L\nn\nTP\n\ + (eleven twelve thirteen fourteen fifteen) Tx\n(\\r) TX\nTO\n"; + +/// [`NAMED_PROLOG_AI`] with `text` on its layer. +fn linked_areas_ai(text: &str) -> String { + NAMED_PROLOG_AI.replace("u\n0.5 g\n", &format!("{text}u\n0.5 g\n")) +} + +/// The fixture, as it is, opens with its area type as two threaded frames, the story flowing from +/// the first into the second. +#[test] +fn the_linked_areas_fixture_opens_as_threaded_frames() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", linked_areas_ai(LINKED_AREAS).as_bytes()).unwrap(); + let [thread] = &l.doc.text_threads[..] else { panic!("one thread: {:?} {:?}", l.doc.text_threads, l.warnings) }; + let texts: Vec = thread + .iter() + .map(|id| match &l.doc.node(*id).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.plain_text(), + k => panic!("type: {k:?}"), + }) + .collect(); + let [first, second] = texts.as_slice() else { panic!("two frames: {texts:?}") }; + assert!(!first.is_empty() && !second.is_empty(), "the story flows on into the second frame: {texts:?}"); + assert_eq!(format!("{first}{second}"), "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen"); +} + +proptest! { + #![proptest_config(config())] + + /// That `.ai` with its linked areas damaged (their text paths, frames and characters): read, or + /// refused. + #[test] + fn ps_form_ai_linked_areas_never_panic(cut in 0usize..400, edits in prop::collection::vec((0usize..400, prop::sample::select(vec!['0', '1', '-', '.', ' ', '\n', '(', ')', '\\', 'T', 'p', 'P', 'O', 'o', 'x', 'X', 'm', 'L', 'n'])), 0..12)) { + let text = linked_areas_ai(&mutate_text(LINKED_AREAS, cut, &edits)); + survive("PostScript-form .ai with damaged linked areas", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + /// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or /// anything else. fn arb_layer_operand() -> impl Strategy { diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 17590b320..5fe9a8ea3 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -1,8 +1,9 @@ //! Type in the legacy format (version 7 specification §15): text objects `type To` … `TO`, their -//! text path (`a b c d tx ty startPt Tp`, an area's frame or the path type runs along, `TP`), the -//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's -//! letter and word spacing `TC TW`, hanging punctuation `Tq` and hyphenation `Th`), the paint style, and -//! the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Final-form operators (the +//! text paths (`a b c d tx ty startPt Tp`, an area's frame, one for each linked area, or the path +//! type runs along, `TP`), the modal character and paragraph attributes +//! (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's letter and word spacing `TC TW`, hanging +//! punctuation `Tq` and hyphenation `Th`), the paint style, and the characters (`Tx`, `Tj`, +//! overflow `TX`; `\r` ends a paragraph). Final-form operators (the //! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the //! file's `TE` encoding, else as Windows-1252; a re-encoded font `/_name` is the font its //! `%AI3_BeginEncoding` names (else `name`). @@ -14,27 +15,46 @@ use vectorcraft_geom::AnchorKind; use super::super::obj::V; use super::art::path_data; -use super::{FLIP, GState, Reader}; +use super::{FLIP, GState, HashSet, Node, NodeId, Reader}; use crate::latin; /// Most characters of type read in one file. const MAX_CHARS: usize = 1 << 22; /// Largest type size and indent read (points). const MAX_SIZE: f64 = 1e4; +/// Most text paths read in one text object (its linked areas); the frames of others are left out, +/// with a warning. +const MAX_TEXT_PATHS: usize = 4096; +/// Most frames of linked areas times characters of their story: the story is laid out again +/// through its frames when the file opens. The frames of the areas past it are left out, with a +/// warning. +const MAX_THREAD_WORK: usize = MAX_CHARS; +/// The warning when [`MAX_TEXT_PATHS`] or [`MAX_THREAD_WORK`] leaves frames of linked areas out. +const LINKED_AREAS_LEFT_OUT: &str = "linked type areas past VectorCraft's limits were left out"; + +/// A text path (`Tp` … `TP`). +#[derive(Debug, Default)] +struct TextPath { + /// `Tp`'s matrix: text space (y up) → art space. + matrix: Option, + /// Where type on a path starts: a segment's index plus how far along it. + start: f64, + /// The frame or path, in art space, and the kind of the anchor each of its segments ends on. + path: Option<(BezPath, Vec)>, +} /// A text object being read. #[derive(Debug, Default)] pub(super) struct LegacyText { /// `To`'s type: 0 point type, 1 area type, 2 type on a path. kind: i64, - /// `Tp`'s matrix: text space (y up) → art space. - matrix: Option, - /// Where type on a path starts: a segment's index plus how far along it. - start: f64, + /// Its text paths in file order: area type has one for each linked area (§15.5.3). + paths: Vec, /// Between `Tp` and `TP`: the path read is the text path. in_path: bool, - /// The frame or path, in art space, and the kind of the anchor each of its segments ends on. - path: Option<(BezPath, Vec)>, + /// It has more than [`MAX_TEXT_PATHS`] text paths: the paths read past them are left out. + past_limit: bool, + /// The story: the characters after every text path, in file order. runs: Vec, /// Each paragraph's attributes, as they were at its first character. paras: Vec, @@ -144,27 +164,28 @@ impl Reader<'_> { 6 => (nums::<6>(vals), None), _ => (None, None), }; - if let Some(m) = m { - let m = Affine::new(m); + let matrix = m.map(Affine::new).filter(|m| { let det = m.determinant(); - if det.is_finite() && det.abs() > 1e-12 { - t.matrix = Some(m); - } + det.is_finite() && det.abs() > 1e-12 + }); + let start = start.filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0); + t.past_limit |= t.paths.len() >= MAX_TEXT_PATHS; + if !t.past_limit { + t.paths.push(TextPath { matrix, start, path: None }); } - t.start = start.filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0); t.in_path = true; } - /// The path painted (or `TP`, ending the text path): the text object's frame or path. + /// The path painted (or `TP`, ending the text path): the text path's frame or path. pub(super) fn text_path(&mut self) { let path = std::mem::take(&mut self.path); let kinds = std::mem::take(&mut self.path_kinds); self.end_path_quietly(); - if let Some(t) = self.text.as_mut() - && t.path.is_none() + if let Some(p) = self.text.as_mut().filter(|t| !t.past_limit).and_then(|t| t.paths.last_mut()) + && p.path.is_none() && !path.elements().is_empty() { - t.path = Some((path, kinds)); + p.path = Some((path, kinds)); } } @@ -358,35 +379,92 @@ impl Reader<'_> { } } - /// `TO`: the text object ends. + /// `TO`: the text object ends. Area type with several linked areas becomes one frame for each + /// area, threaded in file order: the first frame holds the story, and the flow from area to + /// area (final-form, §15.1) is laid out again. The frames are added last to first, so the + /// object's dictionary that follows names the first one ([`Reader::each_linked_frame`]). + /// Point type is read at its first text path; area type and type on a path at the first one + /// that has a frame or path (else at the first one, as point type). pub(super) fn end_text(&mut self, hidden: bool) -> Result<(), String> { let Some(t) = self.text.take() else { return Ok(()) }; if t.runs.is_empty() { return Ok(()); } - let m = t.matrix.unwrap_or(Affine::IDENTITY); let to_doc = self.space(); - // Art space → text space (y down). - let to_text = FLIP * m.inverse(); - let kind = match (t.kind, &t.path) { - (1, Some((p, kinds))) => TextKind::Area { frame: path_data(&(to_text * p.clone()), kinds) }, - (2, Some((p, kinds))) => { - TextKind::OnPath { path: path_data(&(to_text * p.clone()), kinds), start: start_fraction(p, t.start), end: None } - } - _ => TextKind::Point, + let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum(); + let most = (MAX_THREAD_WORK / chars.max(1)).max(1); + let all_areas = t.paths.iter().filter(|p| p.path.is_some()).count(); + let areas: Vec<&TextPath> = t.paths.iter().filter(|p| p.path.is_some()).take(most).collect(); + let placed: Vec<(Affine, TextKind)> = match t.kind { + 1 if areas.len() > 1 => areas.into_iter().map(|p| placed(1, Some(p))).collect(), + 0 => vec![placed(0, t.paths.first())], + kind => vec![placed(kind, areas.first().copied().or(t.paths.first()))], }; - let mut obj = TextObject::point(kurbo::Point::ZERO, "", CharStyle::default()); - obj.kind = kind; - obj.xf = to_doc * m * FLIP; - obj.runs = t.runs; + if t.kind == 1 && (t.past_limit || all_areas > placed.len()) { + self.warn(LINKED_AREAS_LEFT_OUT); + } let mut paras = t.paras; if paras.is_empty() { paras.push(self.type_state.para.clone()); } - obj.set_paragraph_styles(paras); - let node = self.new_node(NodeKind::Text(Box::new(obj)), &t.gs); - self.add(node, t.hidden || hidden) + let last_para = paras.last().cloned().unwrap_or_default(); + let last_style = t.runs.last().map(|r| r.style.clone()).unwrap_or_default(); + let mut story = Some((t.runs, paras)); + let mut nodes = Vec::with_capacity(placed.len()); + for (m, kind) in placed { + let mut obj = TextObject::point(kurbo::Point::ZERO, "", last_style.clone()); + obj.kind = kind; + obj.xf = to_doc * m * FLIP; + match story.take() { + Some((runs, paras)) => { + obj.runs = runs; + obj.set_paragraph_styles(paras); + } + None => obj.set_paragraph_styles(vec![last_para.clone()]), + } + nodes.push(self.new_node(NodeKind::Text(Box::new(obj)), &t.gs)); + } + let frames: Vec = nodes.iter().map(|n| n.id).collect(); + for node in nodes.into_iter().rev() { + self.add(node, t.hidden || hidden)?; + } + if frames.len() > 1 { + self.doc.text_threads.push(frames); + } + Ok(()) } + + /// Call `f` on the other frames of the threaded text object just read, whose first frame is + /// the last object ([`Reader::end_text`]): the opacity mask and text wrap of the object's + /// dictionary apply to every frame. + pub(super) fn each_linked_frame(&mut self, mut f: impl FnMut(&mut Node)) { + if !self.after_object { + return; + } + let Some(first) = self.frames.last().and_then(|fr| fr.children.last()).map(|n| n.id) else { return }; + let Some(thread) = self.doc.text_threads.last().filter(|t| t.first() == Some(&first)) else { return }; + let rest: HashSet = thread.iter().skip(1).copied().collect(); + let Some(fr) = self.frames.last_mut() else { return }; + for n in fr.children.iter_mut().rev().skip(1).take(rest.len()).filter(|n| rest.contains(&n.id)) { + f(n); + } + } +} + +/// A text object of type `kind` on text path `p` (none: at the origin): the matrix from text space +/// (y up) to art space, and the kind of type with its frame or path in text space (y down). +fn placed(kind: i64, p: Option<&TextPath>) -> (Affine, TextKind) { + let m = p.and_then(|p| p.matrix).unwrap_or(Affine::IDENTITY); + // Art space → text space (y down). + let to_text = FLIP * m.inverse(); + let kind = match (kind, p.and_then(|p| p.path.as_ref().map(|path| (path, p.start)))) { + (1, Some(((path, kinds), _))) => TextKind::Area { frame: path_data(&(to_text * path.clone()), kinds) }, + (2, Some(((path, kinds), start))) => { + TextKind::OnPath { path: path_data(&(to_text * path.clone()), kinds), start: start_fraction(path, start), end: None } + } + _ => TextKind::Point, + }; + (m, kind) } /// Where type on `path` starts (`index.fraction`: a segment and how far along it, §15.5.2), as a diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index e07ab498d..f283979c3 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -530,7 +530,8 @@ impl<'a> Reader<'a> { let value = OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") }; if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { - n.extras_mut().mask = Some(Box::new(value)); + n.extras_mut().mask = Some(Box::new(value.clone())); + self.each_linked_frame(|n| n.extras_mut().mask = Some(Box::new(value.clone()))); } else if !on("Disabled") { self.unreadable("opacity masks without an owning object", false); } @@ -553,6 +554,7 @@ impl<'a> Reader<'a> { && let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { n.extras_mut().wrap = Some(wrap); + self.each_linked_frame(|n| n.extras_mut().wrap = Some(wrap)); } // The object's XML id, which the variable library's bindings name it by. if let Some(uid) = o.obj("AI10_ArtUID").and_then(Obj::content_text).filter(|u| !u.is_empty()) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 331e2b257..4361a83e2 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2807,6 +2807,188 @@ fn one_long_run_of_legacy_type_stops_at_the_allowance() { assert_eq!(texts[0].plain_text().chars().count(), ALLOWANCE); } +/// A legacy-format file whose layer holds `art`, read. +fn legacy_read(art: &str) -> Structure { + match read(type_stream7(art).as_bytes()) { + Ok(r) => r, + Err(e) => panic!("{e}"), + } +} + +/// The text objects of the first layer, with their ids, top to bottom: a threaded text object's +/// frames in file order. +fn art_frames(doc: &Document) -> Vec<(vectorcraft_doc::NodeId, vectorcraft_doc::TextObject)> { + art(doc).iter().rev().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((n.id, (**t).clone())) } else { None }).collect() +} + +/// An area's frame in the document. +fn frame_in_doc(t: &vectorcraft_doc::TextObject) -> Rect { + let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { panic!("area type: {:?}", t.kind) }; + kurbo::Shape::bounding_box(&(t.xf * frame.to_bezpath())) +} + +/// Do two matrices match? +fn same_matrix(a: Affine, b: Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(p, q)| (p - q).abs() < 1e-9) +} + +/// Area type with linked areas (§15.5.3): each area's `Tp` … `TP` with its own matrix and frame, +/// the characters after each, and overflow text. It opens as one frame for each area, threaded in +/// file order, with the story in the first. +#[test] +fn legacy_area_type_with_linked_areas_is_threaded_frames() { + let text = format!( + "1 To\n1 0 0 1 0 40 0 Tp\n{}n\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Linked ) Tx\n\ + 0 1 -1 0 150 20 0 Tp\n{}n\nTP\n(areas\\r) Tx\n(and more) TX\n(\\r) TX\nTO\n", + rect_path(0.0, 0.0, 50.0, 40.0), + rect_path(110.0, 20.0, 150.0, 80.0) + ); + let r = legacy_read(&text); + let frames = art_frames(&r.doc); + let [(a, first), (b, second)] = frames.as_slice() else { panic!("two frames: {frames:?} {:?}", r.warnings) }; + assert_eq!(r.doc.text_threads, [vec![*a, *b]], "threaded in file order"); + let in_doc = |x0, y0, x1, y1| r.to_doc.transform_rect_bbox(Rect::new(x0, y0, x1, y1)); + assert!(near(frame_in_doc(first), in_doc(0.0, 0.0, 50.0, 40.0)), "{:?}", frame_in_doc(first)); + assert!(near(frame_in_doc(second), in_doc(110.0, 20.0, 150.0, 80.0)), "{:?}", frame_in_doc(second)); + // Each frame keeps its own area's matrix: the second's type runs up the page. + let placed = |m: [f64; 6]| r.to_doc * Affine::new(m) * Affine::FLIP_Y; + assert!(same_matrix(first.xf, placed([1.0, 0.0, 0.0, 1.0, 0.0, 40.0])), "{:?}", first.xf); + assert!(same_matrix(second.xf, placed([0.0, 1.0, -1.0, 0.0, 150.0, 20.0])), "{:?}", second.xf); + // The story: the characters after every area and the overflow text, in the first frame. + assert_eq!(first.plain_text(), "Linked areas\nand more"); + assert_eq!(first.paragraph_styles().len(), 2); + assert_eq!(second.plain_text(), ""); +} + +/// A linked area without a path is left out; a single area with a path is area type in no thread. +#[test] +fn legacy_area_type_leaves_out_a_linked_area_without_a_path() { + let area = |m: &str, path: Option, chars: &str| { + let path = path.map(|p| format!("{}n\n", rect_path(p.x0, p.y0, p.x1, p.y1))).unwrap_or_default(); + format!("{m} Tp\n{path}TP\n{chars}") + }; + let style = "0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n"; + let three = format!( + "1 To\n{}{}{}TO\n", + area("1 0 0 1 0 40 0", Some(Rect::new(0.0, 0.0, 50.0, 40.0)), &format!("{style}(One ) Tx\n")), + area("1 0 0 1 60 40 0", None, "(two ) Tx\n"), + area("1 0 0 1 110 40 0", Some(Rect::new(110.0, 0.0, 150.0, 40.0)), "(three) Tx\n"), + ); + let r = legacy_read(&three); + let frames = art_frames(&r.doc); + let [(a, first), (b, second)] = frames.as_slice() else { panic!("two frames: {frames:?} {:?}", r.warnings) }; + assert_eq!(r.doc.text_threads, [vec![*a, *b]]); + assert_eq!((first.plain_text().as_str(), second.plain_text().as_str()), ("One two three", "")); + let one = format!( + "1 To\n{}{}TO\n", + area("1 0 0 1 60 40 0", None, &format!("{style}(x) Tx\n")), + area("1 0 0 1 0 40 0", Some(Rect::new(0.0, 0.0, 50.0, 40.0)), "(y) Tx\n") + ); + let r = legacy_read(&one); + let frames = art_frames(&r.doc); + let [(_, only)] = frames.as_slice() else { panic!("one frame: {frames:?}") }; + assert!(r.doc.text_threads.is_empty()); + assert_eq!(only.plain_text(), "xy"); + // The frame's own matrix, not the pathless area's. + assert!(same_matrix(only.xf, r.to_doc * Affine::translate((0.0, 40.0)) * Affine::FLIP_Y), "{:?}", only.xf); +} + +/// One text object reads at most 4,096 linked areas: the paths of the rest are left out, not read as +/// art, and their characters stay in the story. +#[test] +fn legacy_area_type_reads_up_to_its_limit_of_linked_areas() { + const LIMIT: usize = 4096; + let area = format!("1 0 0 1 0 0 0 Tp\n{}n\nTP\n", rect_path(0.0, 0.0, 50.0, 40.0)); + let text = format!("1 To\n{}0 Tr\n(a) Tx\n{area}(b) Tx\nTO\n", area.repeat(LIMIT)); + let r = legacy_read(&text); + let frames = art_frames(&r.doc); + assert_eq!(frames.len(), LIMIT); + assert_eq!(art(&r.doc).len(), LIMIT, "no art paths"); + assert_eq!(r.doc.text_threads.iter().map(Vec::len).collect::>(), [LIMIT]); + assert_eq!(frames[0].1.plain_text(), "ab"); + assert_eq!(r.warnings, [LINKED_AREAS_LEFT_OUT]); +} + +/// The warning when linked areas are left out. +const LINKED_AREAS_LEFT_OUT: &str = "linked type areas past VectorCraft's limits were left out"; + +/// The frames of linked areas times the characters of their story are kept to the reader's +/// allowance of characters: a story of more than half of it gets one frame, and of a third, two. +#[test] +fn legacy_area_type_keeps_its_frames_times_characters_to_the_allowance() { + const ALLOWANCE: usize = 1 << 22; + let area = |x: f64| format!("1 0 0 1 {x} 0 0 Tp\n{}n\nTP\n", rect_path(x, 0.0, x + 50.0, 40.0)); + for (chars, kept) in [(ALLOWANCE / 2 + 1, 1), (ALLOWANCE / 3, 3), (ALLOWANCE / 3 + 1, 2)] { + let text = format!("1 To\n{}0 Tr\n({}) Tx\n{}{}TO\n", area(0.0), "a".repeat(chars), area(60.0), area(120.0)); + let r = legacy_read(&text); + let frames = art_frames(&r.doc); + assert_eq!(frames.len(), kept, "{chars}"); + assert_eq!(r.doc.text_threads.len(), usize::from(kept > 1), "{chars}"); + assert_eq!(frames[0].1.plain_text().len(), chars); + assert_eq!(r.warnings.iter().any(|w| w == LINKED_AREAS_LEFT_OUT), kept < 3, "{chars}"); + } +} + +/// Point type and type on a path have one text path (§15.2): one with more is read at its first. +#[test] +fn legacy_point_and_path_type_with_several_text_paths_use_the_first() { + let point = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n(a) Tx\n1 0 0 1 90 20 0 Tp\nTP\n(b) Tx\nTO\n"; + let r = legacy_read(point); + let frames = art_frames(&r.doc); + let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") }; + assert!(matches!(only.kind, vectorcraft_doc::TextKind::Point) && r.doc.text_threads.is_empty()); + assert_eq!(only.plain_text(), "ab"); + assert!(same_matrix(only.xf, r.to_doc * Affine::translate((10.0, 50.0)) * Affine::FLIP_Y), "the first text path's matrix: {:?}", only.xf); + let on_path = "2 To\n1 0 0 1 0 0 0.5 Tp\n0 0 m\n100 0 L\nN\nTP\n0 Tr\n(a) Tx\n1 0 0 1 0 0 0 Tp\n0 50 m\n100 50 L\nN\nTP\n(b) Tx\nTO\n"; + let r = legacy_read(on_path); + let frames = art_frames(&r.doc); + let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") }; + let vectorcraft_doc::TextKind::OnPath { path, start, .. } = &only.kind else { panic!("{:?}", only.kind) }; + assert!((start - 0.5).abs() < 1e-9, "the first text path's start: {start}"); + let along = kurbo::Shape::bounding_box(&(only.xf * path.to_bezpath())); + assert!((along.y0 - r.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, 100.0, 0.0)).y0).abs() < 1e-9, "{along:?}"); + assert!(same_matrix(only.xf, r.to_doc * Affine::FLIP_Y), "{:?}", only.xf); + // Type on a path whose first text path has no path: the first one with a path, its matrix and + // start included. + let later = "2 To\n1 0 0 1 30 30 0.25 Tp\nTP\n0 Tr\n(a) Tx\n1 0 0 1 5 0 0.5 Tp\n0 50 m\n100 50 L\nN\nTP\n(b) Tx\nTO\n"; + let r = legacy_read(later); + let frames = art_frames(&r.doc); + let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") }; + let vectorcraft_doc::TextKind::OnPath { start, .. } = &only.kind else { panic!("{:?}", only.kind) }; + assert!((start - 0.5).abs() < 1e-9, "{start}"); + assert!(same_matrix(only.xf, r.to_doc * Affine::translate((5.0, 0.0)) * Affine::FLIP_Y), "{:?}", only.xf); + // Area type with no frame at all: point type at its first text path. + let frameless = "1 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n(a) Tx\n1 0 0 1 90 20 0 Tp\nTP\n(b) Tx\nTO\n"; + let r = legacy_read(frameless); + let frames = art_frames(&r.doc); + let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") }; + assert!(matches!(only.kind, vectorcraft_doc::TextKind::Point), "{:?}", only.kind); + assert!(same_matrix(only.xf, r.to_doc * Affine::translate((10.0, 50.0)) * Affine::FLIP_Y), "{:?}", only.xf); +} + +/// The dictionary that follows a threaded text object (version 9 and later): its opacity mask and +/// text wrap go on every frame, and its name on the first frame, which holds the story. +#[test] +fn the_dictionary_after_linked_area_type_applies_to_its_frames() { + let dict = "%_/ArtDictionary :\n%_(Story) /String (AIArtName) ,\n%_12.5 /Real (AI11TextWrapOffset) ,\n\ + %_0 /Bool (AI11TextWrapInvert) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\ + %_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n\ + %_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let area = |x: f64| format!("1 0 0 1 {x} 40 0 Tp\n{}n\nTP\n", rect_path(x, 0.0, x + 50.0, 40.0)); + let text = format!("1 To\n{}0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(a) Tx\n{}{}(b) Tx\nTO\n{dict}", area(0.0), area(60.0), area(120.0)); + let r = read_ok(&layer(&text)); + let ids: Vec<_> = art_frames(&r.doc).into_iter().map(|(id, _)| id).collect(); + assert_eq!(r.doc.text_threads, std::slice::from_ref(&ids), "{:?}", r.warnings); + let nodes: Vec<&Node> = ids.iter().filter_map(|id| r.doc.node(*id)).collect(); + assert_eq!(nodes.len(), 3); + for n in &nodes { + assert!(n.extras().mask.is_some(), "every frame is masked: {:?}", n.id); + assert_eq!(wrap_of(n), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: false })); + } + let names: Vec<_> = nodes.iter().map(|n| n.name.as_deref()).collect(); + assert_eq!(names, [Some("Story"), None, None]); +} + #[test] fn what_it_doesnt_read_is_an_error_where_it_shows() { let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6191a52c5..dad559581 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -120,6 +120,9 @@ private editing data. VectorCraft: without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868, with its paragraphs' letter and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`). Its patterns in the version 7 form (a list of `@` and `&` layers, `_ &` for the tile's background) open as pattern swatches, + with a fill's offset, scale, rotation, reflection and shear applied before its matrix. + Its area type with several linked areas opens as threaded frames, one for each area with its own matrix, + and the story flows through them. with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Its linked images in the version 7 form (`(path) modified XG`, then `XF` with no samples) come in as linked images of their files, showing a placeholder where the image goes until the file is relinked in the Links panel.