Merge pull request #5811 from dotCooCoo/fix/ai-legacy-tab-stops

.ai import: legacy type keeps its tab stops
This commit is contained in:
BFlat
2026-11-05 22:38:33 +07:00
committed by GitHub
6 changed files with 115 additions and 13 deletions
+8
View File
@@ -4107,6 +4107,14 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"7 Tq",
"1 1 1e308 -1e308 4e9 Th",
"1 1 (2) 3 4 Th",
"36 2 Xb",
"Xb",
"0 0 4 1e308 Xe",
"0 0 2 -1e308 Xe",
"0 0 3 72 Xe",
"0 0 1e308 72 Xe",
"(x) /y Xe",
"XB",
"[39/quotesingle 128/Euro/eacute/nosuch 300/x TE",
"[1e308/a -1/b TE",
"\n%AI3_BeginEncoding: _Helvetica Helvetica\n[/_Helvetica/Helvetica 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n",
+42 -2
View File
@@ -2,7 +2,7 @@
//! 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`,
//! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`), the paint style, and the characters (`Tx`, `Tj`,
//! overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the
//! text object clips the objects of its clipping group. Final-form operators (the
//! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the
@@ -12,7 +12,7 @@
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun};
use vectorcraft_geom::AnchorKind;
use super::super::obj::V;
@@ -26,6 +26,10 @@ const MAX_CHARS: usize = 1 << 22;
const MAX_SIZE: f64 = 1e4;
/// Most re-encoded fonts read in one file, for their names and for their encodings each.
const MAX_FONTS: usize = 4096;
/// Most tab stops read in one tab definition.
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 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,
@@ -95,6 +99,8 @@ pub(super) struct TypeState {
font_encodings: Vec<(String, Vec<Option<char>>, bool)>,
/// The font `Tf` set last, as the file names it.
font: Option<String>,
/// Between `Xb` and `XB`: the tab stops of the tab definition read so far.
tabs: Option<Vec<TabStop>>,
chars: usize,
}
@@ -108,6 +114,7 @@ impl Default for TypeState {
fonts: vec![],
font_encodings: vec![],
font: None,
tabs: None,
chars: 0,
}
}
@@ -285,6 +292,8 @@ impl Reader<'_> {
self.unreadable("type in a pattern", false);
}
let kind = nums::<1>(vals).map_or(0, |[k]| k as i64);
// A tab definition left open by an earlier text object (an `Xb` with no `XB`) ends here.
self.type_state.tabs = None;
self.text = Some(LegacyText { kind, gs: self.gs.clone(), hidden, ..LegacyText::default() });
}
@@ -450,6 +459,37 @@ impl Reader<'_> {
h.before_last = letters(tail, 1.0, 15.0);
h.hyphen_limit = if limit { letters(max, 0.0, 25.0) } else { 0 };
}
// A tab definition (§22.6): `numDots numTabs Xb` begins it, each
// `leader decimal tabType tabPosition Xe` describes one tab stop, and `XB` ends it. The
// stops it describes replace the paragraph's tab stops, sorted by position, one stop at
// each position; a definition with none (or whose descriptions were all skipped) leaves
// the default stops. The dot-leader distance, the count and the two reserved operands are
// not read. A description whose type is not 1 to 4 (left, center, right, decimal) or
// whose position is not a number from 0 to [`MAX_TAB_POSITION`] is skipped, and so is one
// outside a tab definition or past the first [`MAX_TABS`] of one. A text object's `To`
// ends a definition that an earlier object left open.
"Xb" => st.tabs = Some(vec![]),
"Xe" => {
let Some(tabs) = st.tabs.as_mut().filter(|t| t.len() < MAX_TABS) else { return };
let Some([kind, position]) = nums::<2>(vals) else { return };
let align = match kind {
1.0 => TabAlign::Left,
2.0 => TabAlign::Center,
3.0 => TabAlign::Right,
4.0 => TabAlign::Decimal,
_ => return,
};
if (0.0..=MAX_TAB_POSITION).contains(&position) {
tabs.push(TabStop { position, align, leader: String::new(), align_on: '.' });
}
}
"XB" => {
if let Some(mut tabs) = st.tabs.take() {
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;
}
}
_ => {}
}
}
+3 -4
View File
@@ -359,16 +359,15 @@ impl<'a> Reader<'a> {
"TO" => self.end_text(hidden)?,
// `(text) justified TS`, special characters (§22.6): the reader adds its characters like `Tx`.
"Tx" | "Tj" | "TX" | "TS" => self.text_body(w, &vals),
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" => self.text_attr(w, &vals),
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "Xb" | "Xe" | "XB" => self.text_attr(w, &vals),
// Attributes the reader doesn't keep (kerning pairs, Far-Eastern type) and final-form
// positions (§15.3).
"Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" | "Tu" | "Xu" => {}
// `TM` (pop text matrix) and `Te` (end render), listed in §22.6, keep nothing either; the
// reader takes `Te` with any number of operands.
"TM" | "Te" => {}
// The hyphenation language and tab rulers, which the reader doesn't keep either (§22): `XL`,
// `Xb`/`XB` a tab definition (and the `Xe` tab descriptions between them).
"XL" | "Xb" | "XB" | "Xe" => {}
// The hyphenation language (`XL`), which the reader doesn't keep either (§22).
"XL" => {}
"TE" => self.te(&vals),
"TZ" => self.tz(&vals),
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
+52
View File
@@ -3261,6 +3261,58 @@ fn legacy_special_characters_join_the_story() {
assert_eq!(t.plain_text(), "Hi");
}
/// A tab definition (`Xb`, `Xe`, `XB`, §22.6) sets the paragraph's tab stops, sorted by position
/// with one stop at each position; each paragraph keeps the stops in force at its first character,
/// and they last into the next text object. A definition with no stops leaves the default ones.
#[test]
fn legacy_tab_definitions_set_the_paragraph_tab_stops() {
use vectorcraft_doc::TabAlign;
let stops = |p: &vectorcraft_doc::ParaStyle| p.tabs.iter().map(|t| (t.position, t.align)).collect::<Vec<_>>();
let ruler = "36 5 Xb\n0 0 3 144 Xe\n0 0 1 72 Xe\n0 0 2 200.5 Xe\n0 0 4 250 Xe\n0 0 2 72 Xe\nXB";
let two_paragraphs = point_type(ruler, "A\\tB\\r").replace("TO\n", "36 0 Xb\nXB\n(C\\tD) Tx\nTO\n");
let texts = legacy_texts(&format!("{two_paragraphs}{}", point_type("", "E")));
assert_eq!(texts.len(), 2);
let want = vec![(72.0, TabAlign::Left), (144.0, TabAlign::Right), (200.5, TabAlign::Center), (250.0, TabAlign::Decimal)];
let one = texts[0].para_at(0);
assert_eq!(stops(one), want);
assert!(one.tabs.iter().all(|t| t.leader.is_empty() && t.align_on == '.'));
assert!(texts[0].para_at(1).tabs.is_empty(), "an empty definition leaves the default stops");
assert!(texts[1].para_at(0).tabs.is_empty(), "the next text object keeps the empty definition");
let texts = legacy_texts(&format!("{}{}", point_type(ruler, "A"), point_type("", "B")));
assert_eq!(stops(texts[1].para_at(0)), want, "the next text object keeps the stops");
}
/// A tab description whose type is not 1 to 4 or whose position is missing, not a number,
/// negative or past 100,000 pt is skipped; so is one outside a tab definition, and an `XB` without
/// an `Xb` leaves the stops as they were.
#[test]
fn legacy_tab_descriptions_out_of_range_are_skipped() {
let bad = "0 0 5 90 Xe\n0 0 0 90 Xe\n0 0 1.5 90 Xe\n0 0 1 -1 Xe\n0 0 1 100001 Xe\n0 0 1 /x Xe\n1 Xe";
let texts = legacy_texts(&point_type(&format!("0 0 1 10 Xe\n36 2 Xb\n{bad}\n0 0 3 100000 Xe\n0 0 1 0 Xe\nXB\nXB\n0 0 1 50 Xe"), "Hi"));
let tabs: Vec<f64> = texts[0].para_at(0).tabs.iter().map(|t| t.position).collect();
assert_eq!(tabs, [0.0, 100_000.0]);
}
/// A tab definition whose descriptions were all skipped leaves the default stops; a definition
/// keeps its first 1024 stops; and a definition left open (an `Xb` with no `XB`) ends at the next
/// text object's `To`, so that object's descriptions outside a definition are skipped.
#[test]
fn legacy_tab_definitions_skipped_capped_and_left_open() {
let positions = |t: &vectorcraft_doc::TextObject| t.para_at(0).tabs.iter().map(|t| t.position).collect::<Vec<f64>>();
let all_skipped = format!("{}{}", point_type("36 1 Xb\n0 0 1 72 Xe\nXB", "A"), point_type("36 1 Xb\n0 0 9 90 Xe\nXB", "B"));
let texts = legacy_texts(&all_skipped);
assert_eq!(positions(&texts[0]), [72.0]);
assert!(texts[1].para_at(0).tabs.is_empty(), "a definition with every stop skipped leaves the default stops");
let many: String = (0..1100).map(|i| format!("0 0 1 {i} Xe\n")).collect();
let texts = legacy_texts(&point_type(&format!("36 1100 Xb\n{many}XB"), "C"));
assert_eq!(positions(&texts[0]).len(), 1024);
assert_eq!(positions(&texts[0]).last(), Some(&1023.0));
let open = format!("{}{}", point_type("36 1 Xb\n0 0 1 72 Xe", "D"), point_type("0 0 1 90 Xe\nXB", "E"));
let texts = legacy_texts(&open);
assert!(texts[0].para_at(0).tabs.is_empty(), "an unended definition sets no stops");
assert!(texts[1].para_at(0).tabs.is_empty(), "the next object's `To` ends the open definition");
}
/// `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]
+7 -5
View File
@@ -209,14 +209,14 @@ fn a_postscript_ai_with_a_multi_layer_mask_opens_with_its_layers() {
}
/// A `.ai` in PostScript form (read through its layers) whose text carries hyphenation and
/// tab-ruler attributes: `XL` (hyphenation language), `Xb`/`XB` (a tab definition) and `Th`
/// (hyphen control). These keep no geometry, so the reader drops them rather than failing the
/// file the way it did before — the symptom seen on Illustrator 6/8 EPS files whose
/// `Adobe_level2_AI5` program can't be run.
/// tab-ruler attributes: `XL` (hyphenation language), `Xb`/`Xe`/`XB` (a tab definition) and `Th`
/// (hyphen control). The reader drops `XL` and keeps the tab definition as the paragraph's tab
/// stops rather than failing the file the way it did before (the symptom seen on Illustrator 6/8
/// EPS files whose `Adobe_level2_AI5` program can't be run).
#[test]
fn text_with_hyphenation_and_tab_attributes_opens_instead_of_failing() {
let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 9 Tf\n\
%_ 0 XL\n36 0 Xb\nXB\n0 1 2 2 99 Th\n0 Ta\n(hi) Tx\nTO\n";
%_ 0 XL\n36 1 Xb\n0 0 3 72 Xe\nXB\n0 1 2 2 99 Th\n0 Ta\n(hi) Tx\nTO\n";
// The operators sit on a visible layer (so they'd be reported as unknown, not quietly skipped
// on a hidden one), beside the square so the layer is never empty.
let src = NAMED_PROLOG.replace("S\nLB\n%AI5_EndLayer--\n%AI5_BeginLayer", &format!("S\n{text}LB\n%AI5_EndLayer--\n%AI5_BeginLayer"));
@@ -234,6 +234,8 @@ fn text_with_hyphenation_and_tab_attributes_opens_instead_of_failing() {
let [p] = texts.as_slice() else { panic!("{texts:?}") };
assert!(!p.hyphenate);
assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (2, 2, 25));
let tabs: Vec<_> = p.tabs.iter().map(|t| (t.position, t.align)).collect();
assert_eq!(tabs, [(72.0, vectorcraft_doc::TabAlign::Right)]);
}
/// An Illustrator 3 file of paths alone, as image editors export their paths (storytold/photocraft#6036):
+3 -2
View File
@@ -125,8 +125,9 @@ private editing data. VectorCraft:
and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`). 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. Its patterns in
and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`); its special characters
(`TS`) join the story, and end render (`Te`) and pop text matrix (`TM`) are read and ignored. Its patterns in
and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`); its tab definitions
(`Xb` … `XB`) set its paragraphs' tab stops; its special characters (`TS`) join the story, and end render
(`Te`) and pop text matrix (`TM`) are read and ignored. 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,