Merge pull request #5885 from dotCooCoo/fix/ai-tab-stops-shared

.ai import: legacy type keeps one copy of tab stops its paragraphs share
This commit is contained in:
BFlat
2026-11-06 14:34:55 +08:00
committed by GitHub
3 changed files with 167 additions and 12 deletions
+108 -11
View File
@@ -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<AnchorKind>)>,
}
/// 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<TextRun>,
/// Each paragraph's attributes, as they were at its first character.
paras: Vec<ParaStyle>,
/// The paragraphs' attributes, as they were at the first character of each, by runs of
/// paragraphs with the same attributes.
paras: Vec<ParaRun>,
/// 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<ParaRun>, copied: &mut usize, left_out: &mut bool) -> Vec<ParaStyle> {
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<NodeId> = 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
+55
View File
@@ -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(&paragraphs_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(&paragraphs_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::<usize>(), 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<usize> = 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";
+4 -1
View File
@@ -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.