From f092b0b7a790bfd7929ea5e22770fb8230a7eed7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 07:01:08 -0800 Subject: [PATCH] .ai import: legacy type keeps its tab stops A tab definition in type of the legacy format (`numDots numTabs Xb`, a `leader decimal tabType tabPosition Xe` for each stop, then `XB`) was skipped, so every paragraph opened with only the default stops every 36 pt. The stops it describes now become the paragraph's tab stops (left, center, right or decimal, at their positions), and they last into the next paragraphs and text objects like the other paragraph attributes. A definition with no stops, or whose descriptions were all skipped, leaves the default ones. A description with an unknown type or a position outside 0 to 100000 pt is skipped, and so is one past the first 1024 of a definition. A text object's `To` ends a definition that an earlier object left open. Docs: docs/file-format-parity.md. --- crates/engine/tests/import_fuzz.rs | 8 ++++ crates/eps/src/import/ai/legacy_text.rs | 44 +++++++++++++++++- crates/eps/src/import/ai/read/ops.rs | 7 ++- crates/eps/src/import/ai/tests.rs | 52 ++++++++++++++++++++++ crates/eps/src/import/tests_illustrator.rs | 12 ++--- docs/file-format-parity.md | 5 ++- 6 files changed, 115 insertions(+), 13 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index bf0967283..8c2fec772 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4107,6 +4107,14 @@ fn arb_ai_token() -> impl Strategy { "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", diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index c510f6ceb..f61fae77e 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -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>, bool)>, /// The font `Tf` set last, as the file names it. font: Option, + /// Between `Xb` and `XB`: the tab stops of the tab definition read so far. + tabs: Option>, 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; + } + } _ => {} } } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 07bdb9564..110c72d8e 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -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`). diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5c6096229..79cd13c6b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -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::>(); + 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 = 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::>(); + 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] diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index 065caa066..b896063aa 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -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): diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index da9947e36..2108e78dd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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,