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.