From aac21aa0768e1d5e522305c26714a48ea51c4c4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 13:22:36 +0700 Subject: [PATCH] .ai import: legacy type keeps one copy of tab stops its paragraphs share --- crates/eps/src/import/ai/legacy_text.rs | 119 +++++++++++++++++++++--- crates/eps/src/import/ai/tests.rs | 55 +++++++++++ docs/file-format-parity.md | 5 +- 3 files changed, 167 insertions(+), 12 deletions(-) diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index f61fae77e..04e181ed4 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -30,6 +30,13 @@ const MAX_FONTS: usize = 4096; const MAX_TABS: usize = 1024; /// Largest tab stop position read (points), as `text.tabs.set` takes it. const MAX_TAB_POSITION: f64 = 100_000.0; +/// Most tab stops copied in one text object into its runs of paragraphs with the same attributes +/// while it is read, and again, counted apart, into each of its paragraphs when it has more than one +/// run and into the frames of its linked areas after the first. The runs, paragraphs and frames past +/// it take the default stops, with a warning. +const MAX_STORY_TABS: usize = 1 << 20; +/// The warning when [`MAX_STORY_TABS`] leaves tab stops out. +const TABS_LEFT_OUT: &str = "tab stops past VectorCraft's limits were left out"; /// Most values read before one operator in a re-encoding section. const MAX_ENCODING_VALUES: usize = 1024; /// Most text paths read in one text object (its linked areas); the frames of others are left out, @@ -56,6 +63,17 @@ struct TextPath { path: Option<(BezPath, Vec)>, } +/// Paragraphs in a row that start with the same attributes. +#[derive(Debug)] +struct ParaRun { + /// The attributes, as they were at the first character of each paragraph. + style: ParaStyle, + /// How many paragraphs. + count: usize, + /// [`TypeState::tabs_version`] when the last of them started. + tabs_version: u64, +} + /// A text object being read. #[derive(Debug, Default)] pub(super) struct LegacyText { @@ -69,8 +87,13 @@ pub(super) struct LegacyText { 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, + /// The paragraphs' attributes, as they were at the first character of each, by runs of + /// paragraphs with the same attributes. + paras: Vec, + /// The tab stops that the runs in `paras` hold. + tab_stops: usize, + /// [`MAX_STORY_TABS`] left tab stops out. + tabs_left_out: bool, /// The current paragraph's attributes are in `paras`. para_taken: bool, /// An overflow paragraph break (`(\r) TX`) not yet followed by type: the story's end. @@ -83,11 +106,74 @@ pub(super) struct LegacyText { hidden: bool, } +impl LegacyText { + /// A paragraph starts, with the attributes `para` at [`TypeState::tabs_version`] + /// `tabs_version`. When they are those of the paragraph before it, it joins that paragraph's + /// run; else a new run starts, with the tab stops that [`MAX_STORY_TABS`] leaves room for. + fn begin_para(&mut self, para: &mut ParaStyle, tabs_version: u64) { + if let Some(run) = self.paras.last_mut() { + // No tab definition since the run's last paragraph started: the stops are the same. + let same = if run.tabs_version == tabs_version { same_but_tabs(&mut run.style, para) } else { run.style == *para }; + if same { + run.count += 1; + run.tabs_version = tabs_version; + return; + } + } + let style = within_tabs(para, &mut self.tab_stops, &mut self.tabs_left_out); + self.paras.push(ParaRun { style, count: 1, tabs_version }); + } +} + +/// Are `a` and `b` the same, leaving their tab stops out? +fn same_but_tabs(a: &mut ParaStyle, b: &mut ParaStyle) -> bool { + let (a_tabs, b_tabs) = (std::mem::take(&mut a.tabs), std::mem::take(&mut b.tabs)); + let same = a == b; + (a.tabs, b.tabs) = (a_tabs, b_tabs); + same +} + +/// A copy of `para`, with its tab stops when `copied` (the tab stops copied so far) has room for +/// them within [`MAX_STORY_TABS`], else with the default stops and `left_out` set. +fn within_tabs(para: &ParaStyle, copied: &mut usize, left_out: &mut bool) -> ParaStyle { + let mut style = para.clone(); + if MAX_STORY_TABS.saturating_sub(*copied) >= style.tabs.len() { + *copied += style.tabs.len(); + } else { + *left_out = true; + style.tabs = Vec::new(); + } + style +} + +/// The attributes of each paragraph of the runs `runs`, in order. A paragraph keeps its tab stops +/// while `copied` (the tab stops copied so far) has room for them within [`MAX_STORY_TABS`]; the +/// paragraphs past it take the default stops, and `left_out` is set. +fn each_paragraph(runs: Vec, copied: &mut usize, left_out: &mut bool) -> Vec { + let mut each = Vec::with_capacity(runs.iter().map(|r| r.count).sum()); + for run in runs { + let stops = run.style.tabs.len(); + let room = MAX_STORY_TABS.saturating_sub(*copied); + let kept = room.checked_div(stops).map_or(run.count, |n| run.count.min(n)); + *copied += kept * stops; + let bare = (kept < run.count).then(|| ParaStyle { tabs: Vec::new(), ..run.style.clone() }); + each.extend(std::iter::repeat_n(run.style, kept)); + if let Some(bare) = bare { + *left_out = true; + each.extend(std::iter::repeat_n(bare, run.count - kept)); + } + } + each +} + /// The modal type attributes of the legacy format, which last from one text object to the next. #[derive(Debug)] pub(super) struct TypeState { style: CharStyle, para: ParaStyle, + /// How many tab definitions set `para`'s tab stops: paragraphs that start at the same count + /// start with the same stops. + tabs_version: u64, /// `Tr`. render: i64, /// The encoding strings are in, by code (`TE`); None where it says nothing. @@ -109,6 +195,7 @@ impl Default for TypeState { Self { style: CharStyle::default(), para: ParaStyle::default(), + tabs_version: 0, render: 0, encoding: vec![None; 256], fonts: vec![], @@ -488,6 +575,7 @@ impl Reader<'_> { tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); st.para.tabs = tabs; + st.tabs_version = st.tabs_version.wrapping_add(1); } } _ => {} @@ -538,13 +626,13 @@ impl Reader<'_> { } for c in decoded.chars() { if c != '\n' && !t.para_taken { - t.paras.push(st.para.clone()); + t.begin_para(&mut st.para, st.tabs_version); t.para_taken = true; } text.push(c); if c == '\n' { if !t.para_taken { - t.paras.push(st.para.clone()); + t.begin_para(&mut st.para, st.tabs_version); } t.para_taken = false; } @@ -562,7 +650,7 @@ impl Reader<'_> { /// 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(()) }; + let Some(mut t) = self.text.take() else { return Ok(()) }; if t.runs.is_empty() { return Ok(()); } @@ -581,13 +669,15 @@ impl Reader<'_> { if t.kind == 1 && (t.past_limit || all_areas > placed.len()) { self.warn(LINKED_AREAS_LEFT_OUT); } - let mut paras = t.paras; + let mut paras = std::mem::take(&mut t.paras); if paras.is_empty() { - paras.push(self.type_state.para.clone()); + paras.push(ParaRun { style: self.type_state.para.clone(), count: 1, tabs_version: 0 }); } - let last_para = paras.last().cloned().unwrap_or_default(); + let last_para = paras.last().map(|r| r.style.clone()).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 story = Some((std::mem::take(&mut t.runs), paras)); + // The tab stops copied into the paragraphs and frames. + let mut copied = 0; let mut nodes = Vec::with_capacity(placed.len()); for (m, kind) in placed { let mut obj = TextObject::point(kurbo::Point::ZERO, "", last_style.clone()); @@ -596,12 +686,19 @@ impl Reader<'_> { match story.take() { Some((runs, paras)) => { obj.runs = runs; - obj.set_paragraph_styles(paras); + // Paragraphs that all have the same attributes share one copy of them. + match <[ParaRun; 1]>::try_from(paras) { + Ok([one]) => obj.set_all_paras(one.style), + Err(paras) => obj.set_paragraph_styles(each_paragraph(paras, &mut copied, &mut t.tabs_left_out)), + } } - None => obj.set_paragraph_styles(vec![last_para.clone()]), + None => obj.set_all_paras(within_tabs(&last_para, &mut copied, &mut t.tabs_left_out)), } nodes.push(self.new_node(NodeKind::Text(Box::new(obj)), &t.gs)); } + if t.tabs_left_out { + self.warn(TABS_LEFT_OUT); + } let frames: Vec = nodes.iter().map(|n| n.id).collect(); // Type used as a mask clips the objects of its clipping group, as a `W` path does. A // clipping path is one object, and wraparound text and compound paths don't clip their diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 839007232..e7bdd2bd7 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3313,6 +3313,44 @@ fn legacy_tab_definitions_skipped_capped_and_left_open() { assert!(texts[1].para_at(0).tabs.is_empty(), "the next object's `To` ends the open definition"); } +/// Point type whose 1024 tab stops are followed by 2048 paragraph breaks, each after `attrs(i)`. +fn paragraphs_after_many_tabs(attrs: impl Fn(usize) -> String) -> String { + let many: String = (0..1024).map(|i| format!("0 0 1 {i} Xe\n")).collect(); + let breaks: String = (0..2048).map(|i| format!("{}(x\\r) Tx\n", attrs(i))).collect(); + point_type(&format!("36 1024 Xb\n{many}XB"), "A").replace("TO\n", &format!("{breaks}TO\n")) +} + +/// Paragraphs that start with the same attributes all keep the same tab stops, however many +/// there are, also when an attribute is set again to the value it has. +#[test] +fn legacy_tab_stops_last_through_many_paragraphs() { + for again in ["", "0 Ta\n"] { + let r = legacy_read(¶graphs_after_many_tabs(|_| again.to_string())); + let texts = art_texts(&r.doc); + let [t] = texts.as_slice() else { panic!("{texts:?}") }; + assert_eq!(t.paragraph_count(), 2049); + assert!((0..2049).all(|i| t.para_at(i).tabs.len() == 1024), "{again}"); + assert!(r.warnings.is_empty(), "{again}: {:?}", r.warnings); + } +} + +/// Each paragraph whose attributes differ from the paragraph before it holds its own copy of its +/// tab stops, and a text object's paragraphs hold no more than 1,048,576 stops: the paragraphs +/// past them take the default stops and keep their other attributes, with a warning. +#[test] +fn legacy_tab_stops_copied_into_paragraphs_are_capped() { + let r = legacy_read(¶graphs_after_many_tabs(|i| format!("{} Ta\n", i % 2))); + let texts = art_texts(&r.doc); + let [t] = texts.as_slice() else { panic!("{texts:?}") }; + let paras = t.paragraph_styles(); + assert_eq!(paras.len(), 2049); + assert_eq!(paras.iter().map(|p| p.tabs.len()).sum::(), 1 << 20); + assert!(paras[..1024].iter().all(|p| p.tabs.len() == 1024)); + assert!(paras[1024..].iter().all(|p| p.tabs.is_empty())); + assert_eq!((paras[2046].justify, paras[2047].justify), (vectorcraft_doc::Justify::Left, vectorcraft_doc::Justify::Center)); + assert_eq!(r.warnings, ["tab stops past VectorCraft's limits were left out"]); +} + /// `Te` (end render, §22.6) reads in a text object with no operands and with four, inside a tab /// definition too, and keeps nothing; so does `TM` (pop text matrix, §22.6). #[test] @@ -3625,6 +3663,23 @@ fn legacy_area_type_reads_up_to_its_limit_of_linked_areas() { assert_eq!(r.warnings, [LINKED_AREAS_LEFT_OUT]); } +/// The frames of linked areas after the first copy the story's last paragraph attributes, and +/// those copies hold no more than 1,048,576 tab stops: the frames past them take the default +/// stops, with a warning. +#[test] +fn legacy_linked_area_frames_cap_their_copied_tab_stops() { + let many: String = (0..1024).map(|i| format!("0 0 1 {i} Xe\n")).collect(); + 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\n36 1024 Xb\n{many}XB\n(a) Tx\nTO\n", area.repeat(4096)); + let r = legacy_read(&text); + let frames = art_frames(&r.doc); + assert_eq!(frames.len(), 4096); + let stops: Vec = frames.iter().map(|(_, f)| f.para_at(0).tabs.len()).collect(); + assert_eq!(stops.iter().filter(|&&n| n == 1024).count(), 1025); + assert_eq!(stops.iter().filter(|&&n| n == 0).count(), 3071); + assert_eq!(r.warnings, ["tab stops past VectorCraft's limits were 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"; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a63c5464b..fabf40507 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -109,7 +109,10 @@ private editing data. VectorCraft: open live); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). Legacy `.ai` type keeps its paragraphs' letter and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and - hyphenation (`Th`); its tab definitions (`Xb` … `XB`) set its paragraphs' tab stops; its special characters + hyphenation (`Th`); its tab definitions (`Xb` … `XB`) set its paragraphs' tab stops. Paragraphs in a row with + the same attributes share one copy of them while the text object is read, and those copies hold at most + 1,048,576 stops. The paragraphs and linked frames of one text object then get at most 1,048,576 more copied + stops. Paragraphs and frames past either limit take the default stops, with a warning. Its special characters (`TS`) join the story, and end render (`Te`) and pop text matrix (`TM`) are read and ignored. Its type in a font re-encoded with encoding pairs of its own (`TZ`) is decoded with those pairs, and with the file's encoding (`TE`) for the codes they leave out unless the `TZ` sets `useDefault` to 0.