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,