From db1144650232e481c3c9c3bffbfd399e349a203e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:55:44 -0800 Subject: [PATCH] .ai import: legacy type keeps its spacing, hanging punctuation and hyphenation Legacy type (`To` ... `TO`) now reads `min opt max TC` and `min opt max TW` into the paragraph's letter and word spacing, `hangingQuotes Tq` into Roman hanging punctuation, and `hyphenate limit lead tail max Th` into hyphenation on or off, After First, Before Last and the Hyphen Limit (0 when the limit flag is 0). Values are kept to the ranges the Justification and Hyphenation options take, and a spacing triple out of order goes back to its default. Operands of the legacy type attributes are read by their place: an attribute whose operands are missing, are not numbers, or are flags other than 0 and 1 stays as it was. Before, a value that was not a number shifted the later operands (`5 /x 7 Ti` set indents of 5, 7 and 0), and a bad `TA` or `Ta` turned kerning on or set the alignment to left. One long `Tx` no longer goes past the reader's limit on characters. --- crates/engine/tests/import_fuzz.rs | 7 + crates/eps/src/import/ai/legacy_text.rs | 158 ++++++++++++++++----- crates/eps/src/import/ai/read/ops.rs | 16 +-- crates/eps/src/import/ai/tests.rs | 107 ++++++++++++++ crates/eps/src/import/tests_illustrator.rs | 11 ++ docs/file-format-parity.md | 3 +- 6 files changed, 258 insertions(+), 44 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 931963de3..c54c8e133 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3310,6 +3310,13 @@ fn arb_ai_token() -> impl Strategy { "9 Ta", "1e308 -1e308 1e308 Ti", "0 1 Tk", + "-1e308 0 1e308 TC", + "1e308 -1 5 TW", + "/x 100 200 TW", + "1 Tq", + "7 Tq", + "1 1 1e308 -1e308 4e9 Th", + "1 1 (2) 3 4 Th", "[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 126619c13..fec253930 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -1,6 +1,7 @@ //! Type in the legacy format (version 7 specification §15): text objects `type To` … `TO`, their //! text path (`a b c d tx ty startPt Tp`, an area's frame or the path type runs along, `TP`), the -//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`), the paint style, and +//! 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`, overflow `TX`; `\r` ends a paragraph). Final-form operators (the //! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the //! file's `TE` encoding, else as Windows-1252; a re-encoded font `/_name` is the font its @@ -78,8 +79,23 @@ impl TypeState { } } -fn num(vals: &[V], i: usize) -> Option { - vals.iter().filter_map(V::num).nth(i).filter(|v| v.is_finite()) +/// An operator's `N` operands, the last `N` values before it, when each is a finite number. +fn nums(vals: &[V]) -> Option<[f64; N]> { + let operands = vals.get(vals.len().checked_sub(N)?..)?; + let mut out = [0.0; N]; + for (o, v) in out.iter_mut().zip(operands) { + *o = v.num().filter(|n| n.is_finite())?; + } + Some(out) +} + +/// A flag operand: 0 or 1. +fn flag(v: f64) -> Option { + match v { + 0.0 => Some(false), + 1.0 => Some(true), + _ => None, + } } impl Reader<'_> { @@ -110,23 +126,28 @@ impl Reader<'_> { /// `type To`: a text object begins. pub(super) fn begin_text(&mut self, vals: &[V], hidden: bool) { self.end_path_quietly(); - let kind = num(vals, 0).map_or(0, |k| k as i64); + let kind = nums::<1>(vals).map_or(0, |[k]| k as i64); self.text = Some(LegacyText { kind, gs: self.gs.clone(), hidden, ..LegacyText::default() }); } /// `a b c d tx ty startPt Tp`: the text path begins. pub(super) fn begin_text_path(&mut self, vals: &[V]) { self.end_path_quietly(); - let n: Vec = vals.iter().filter_map(V::num).collect(); let Some(t) = self.text.as_mut() else { return }; - if let Some(m) = n.get(..6).and_then(|m| <[f64; 6]>::try_from(m).ok()) { + // Seven operands, or the matrix alone. + let (m, start) = match vals.len() { + 7 => (vals.get(..6).and_then(nums::<6>), vals.get(6).and_then(V::num)), + 6 => (nums::<6>(vals), None), + _ => (None, None), + }; + if let Some(m) = m { let m = Affine::new(m); let det = m.determinant(); - if m.as_coeffs().iter().all(|v| v.is_finite()) && det.is_finite() && det.abs() > 1e-12 { + if det.is_finite() && det.abs() > 1e-12 { t.matrix = Some(m); } } - t.start = n.get(6).copied().filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0); + t.start = start.filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0); t.in_path = true; } @@ -150,56 +171,122 @@ impl Reader<'_> { } } - /// A character or paragraph attribute. + /// A character or paragraph attribute. An attribute whose operands are missing, are not + /// numbers, or are flags other than 0 and 1 stays as it was. pub(super) fn text_attr(&mut self, w: &str, vals: &[V]) { let st = &mut self.type_state; - let n0 = num(vals, 0); + let n0 = nums::<1>(vals).map(|[v]| v); match w { - "Tr" => st.render = n0.map_or(0, |v| v as i64), + "Tr" => { + if let Some(v) = n0 { + st.render = v as i64; + } + } "Tf" => { - if let Some(name) = vals.iter().find_map(|v| if let V::Name(n) = v { Some(n.clone()) } else { None }) { - let name = st - .fonts - .iter() - .find(|(new, _)| *new == name) - .map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone()); + // `/name size Tf`, or `/name size ascent descent Tf`. + let Some(at) = vals.iter().position(|v| matches!(v, V::Name(_))) else { return }; + if let Some(V::Name(name)) = vals.get(at) { + let name = + st.fonts.iter().find(|(new, _)| new == name).map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone()); let (family, style) = super::super::super::text::family_style(&name); st.style.font_family = family; st.style.font_style = style; st.style.font_version = None; } - if let Some(size) = num(vals, 0).filter(|s| *s > 0.0) { + if let Some(size) = vals.get(at + 1).and_then(V::num).filter(|s| s.is_finite() && *s > 0.0) { st.style.size = size.min(MAX_SIZE); } } - "Ts" => st.style.baseline_shift = n0.unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE), + "Ts" => { + if let Some(v) = n0 { + st.style.baseline_shift = v.clamp(-MAX_SIZE, MAX_SIZE); + } + } "Tz" => { + // `parallelScale perpendicularScale Tz`; before version 7, `scale Tz`. let scale = |v: f64| v.clamp(1.0, 10_000.0); - st.style.h_scale = n0.map_or(100.0, scale); - st.style.v_scale = num(vals, 1).map_or(100.0, scale); + let scales = match vals.len() { + 2 => nums::<2>(vals), + 1 => n0.map(|h| [h, 100.0]), + _ => None, + }; + if let Some([h, v]) = scales { + (st.style.h_scale, st.style.v_scale) = (scale(h), scale(v)); + } + } + "Tt" => { + if let Some(v) = n0 { + st.style.tracking = v.clamp(-10_000.0, 10_000.0); + } + } + "TA" => { + if let Some(auto) = n0.and_then(flag) { + st.style.kerning = if auto { None } else { Some(0.0) }; + } } - "Tt" => st.style.tracking = n0.unwrap_or(0.0).clamp(-10_000.0, 10_000.0), - "TA" => st.style.kerning = if n0 == Some(0.0) { Some(0.0) } else { None }, "Tl" => { - if let Some(l) = n0 { + // `leading paragraphLeading Tl`; before version 7, `leading Tl`. + let leading = match vals.len() { + 2 => nums::<2>(vals), + 1 => n0.map(|l| [l, 0.0]), + _ => None, + }; + if let Some([l, before]) = leading { let l = l.clamp(0.0, MAX_SIZE); // The automatic leading, 120% of the size, stays automatic. st.style.leading = if (l - st.style.size * 1.2).abs() < 1e-3 { None } else { Some(l) }; + st.para.space_before = before.clamp(-MAX_SIZE, MAX_SIZE); } - st.para.space_before = num(vals, 1).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE); } "Ta" => { - st.para.justify = match n0.map(|v| v as i64) { - Some(1) => Justify::Center, - Some(2) => Justify::Right, - Some(3) => Justify::JustifyLeft, - Some(4) => Justify::JustifyAll, - _ => Justify::Left, + let justify = match n0 { + Some(0.0) => Some(Justify::Left), + Some(1.0) => Some(Justify::Center), + Some(2.0) => Some(Justify::Right), + Some(3.0) => Some(Justify::JustifyLeft), + Some(4.0) => Some(Justify::JustifyAll), + _ => None, + }; + if let Some(j) = justify { + st.para.justify = j; } } "Ti" => { - let indent = |i: usize| num(vals, i).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE); - (st.para.first_line_indent, st.para.left_indent, st.para.right_indent) = (indent(0), indent(1), indent(2)); + if let Some(i) = nums::<3>(vals) { + let [first, left, right] = i.map(|v| v.clamp(-MAX_SIZE, MAX_SIZE)); + (st.para.first_line_indent, st.para.left_indent, st.para.right_indent) = (first, left, right); + } + } + // `min opt max TC` (letter spacing) and `min opt max TW` (word spacing), in percent of + // a space's width, as the paragraph's Justification options take them. + "TC" | "TW" => { + if let Some(v) = nums::<3>(vals) { + let j = &mut st.para.justification; + if w == "TC" { + j.letter_spacing = v; + } else { + j.word_spacing = v; + } + j.keep_in_range(); + } + } + // `hangingQuotes Tq`. + "Tq" => { + if let Some(on) = n0.and_then(flag) { + st.para.roman_hanging = on; + } + } + // `hyphenate limitHyphLines minLeadHyphen minTailHyphen maxHyphLines Th`, kept to the + // ranges `text.setFormat` takes. + "Th" => { + let Some([on, limit, lead, tail, max]) = nums::<5>(vals) else { return }; + let (Some(on), Some(limit)) = (flag(on), flag(limit)) else { return }; + let letters = |v: f64, lo: f64, hi: f64| v.round().clamp(lo, hi) as u32; + st.para.hyphenate = on; + let h = &mut st.para.hyphenation; + h.after_first = letters(lead, 1.0, 15.0); + h.before_last = letters(tail, 1.0, 15.0); + h.hyphen_limit = if limit { letters(max, 0.0, 25.0) } else { 0 }; } _ => {} } @@ -222,9 +309,12 @@ impl Reader<'_> { t.pending_break = true; return; } - if st.chars >= MAX_CHARS { + // As many characters as the file's allowance has left. + let room = MAX_CHARS.saturating_sub(st.chars); + if room == 0 { return; } + let decoded: String = decoded.chars().take(room).collect(); st.chars += decoded.chars().count(); let gs = &self.gs; let mut style = st.style.clone(); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 7e7b79746..f48a8df82 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -276,15 +276,13 @@ impl<'a> Reader<'a> { "TP" => self.end_text_path(), "TO" => self.end_text(hidden)?, "Tx" | "Tj" | "TX" => self.text_body(w, &vals), - "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals), - // Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation, - // Far-Eastern type) and final-form positions (§15.3). - "Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" - | "Tu" | "Xu" => {} - // Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th` - // hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe` - // tab descriptions between them). - "Th" | "XL" | "Xb" | "XB" | "Xe" => {} + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" => 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" => {} + // 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" => {} "TE" => self.te(&vals), // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). "XI" if self.raster.is_some() => { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 4d3e5e01c..c53209fbc 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3149,3 +3149,110 @@ fn an_objects_on_path_group_in_versioned_content_opens_once() { assert_eq!(kids[0].children().unwrap().len(), 5); assert!(kids[0].extras().objects_on_path.is_some()); } + +/// A file in the legacy format (version 7) whose layer holds `art`, crop marks 0 0 200 100. +fn stream7(art: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 3.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n", + layer(art) + ) +} + +/// The text objects of a legacy-format file whose layer holds `art`. +fn legacy_texts(art: &str) -> Vec { + let doc = match read(stream7(art).as_bytes()) { + Ok(r) => r.doc, + Err(e) => panic!("{e}"), + }; + art_texts(&doc) +} + +fn art_texts(doc: &Document) -> Vec { + art(doc).iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).collect() +} + +/// Point type at (10, 50): `attrs`, then the characters `text`. +fn point_type(attrs: &str, text: &str) -> String { + format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n{attrs}\n({text}) Tx\nTO\n") +} + +/// `TC` and `TW` (§15.4.7) set the paragraph's letter and word spacing, `Tq` its hanging +/// punctuation and `Th` (§22) its hyphenation; each paragraph keeps the values in force at its +/// first character, and they last into the next text object. +#[test] +fn legacy_type_keeps_its_spacing_hanging_punctuation_and_hyphenation() { + let two_paragraphs = + point_type("10 15 20 TC\n70 110 140 TW\n1 Tq\n1 1 2 3 4 Th", "One\\r").replace("TO\n", "0 0 5 TC\n0 Tq\n0 0 4 5 6 Th\n(Two) Tx\nTO\n"); + let texts = legacy_texts(&format!("{two_paragraphs}{}", point_type("", "Three"))); + assert_eq!(texts.len(), 2); + let one = texts[0].para_at(0); + assert_eq!(one.justification.letter_spacing, [10.0, 15.0, 20.0]); + assert_eq!(one.justification.word_spacing, [70.0, 110.0, 140.0]); + assert!(one.roman_hanging && one.hyphenate); + let h = &one.hyphenation; + assert_eq!((h.after_first, h.before_last, h.hyphen_limit), (2, 3, 4)); + assert_eq!(h.words_longer_than, vectorcraft_doc::text::HyphenationOptions::default().words_longer_than, "no operand sets it"); + let two = texts[0].para_at(1); + assert_eq!(two.justification.letter_spacing, [0.0, 0.0, 5.0]); + assert_eq!(two.justification.word_spacing, [70.0, 110.0, 140.0], "word spacing stays"); + assert!(!two.roman_hanging && !two.hyphenate); + // Without the limit flag, any number of hyphens in a row. + assert_eq!((two.hyphenation.after_first, two.hyphenation.before_last, two.hyphenation.hyphen_limit), (4, 5, 0)); + // The next text object keeps them. + assert_eq!(texts[1].para_at(0), two); +} + +/// A legacy attribute whose operands are missing, are not numbers, or are flags other than 0 and +/// 1 leaves the attribute as it was, and operands are read by their place. +#[test] +fn legacy_type_attributes_with_bad_operands_stay_as_they_were() { + let set = "10 15 20 TC\n70 110 140 TW\n1 Tq\n1 1 2 3 4 Th\n2 Ta\n0 TA\n5 6 7 Ti"; + let bad = "/x 15 20 TC\n110 140 TW\n2 Tq\n1 2 2 3 4 Th\n1 1 (2) 3 4 Th\n/x Ta\n2 TA\n5 /x 7 Ti"; + let texts = legacy_texts(&point_type(&format!("{set}\n{bad}"), "Hi")); + let p = texts[0].para_at(0); + assert_eq!(p.justification.letter_spacing, [10.0, 15.0, 20.0]); + assert_eq!(p.justification.word_spacing, [70.0, 110.0, 140.0]); + assert!(p.roman_hanging && p.hyphenate); + assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (2, 3, 4)); + assert_eq!(p.justify, vectorcraft_doc::Justify::Right); + assert_eq!((p.first_line_indent, p.left_indent, p.right_indent), (5.0, 6.0, 7.0)); + assert_eq!(texts[0].runs[0].style.kerning, Some(0.0), "kerning stays off"); + // A scale or leading pair with a bad operand leaves both as they were; one operand is the + // horizontal scale (or the leading) of the format before version 7. + let texts = legacy_texts(&point_type("50 80 Tz\n14 3 Tl\n/x 120 Tz\n/x 20 Tl", "Hi")); + let st = &texts[0].runs[0].style; + assert_eq!((st.h_scale, st.v_scale, st.leading), (50.0, 80.0, Some(14.0))); + assert_eq!(texts[0].para_at(0).space_before, 3.0); + let st = &legacy_texts(&point_type("50 80 Tz\n120 Tz", "Hi"))[0].runs[0].style; + assert_eq!((st.h_scale, st.v_scale), (120.0, 100.0)); + // A start point that isn't a number leaves the matrix. + let good = &legacy_texts(&point_type("", "Hi"))[0]; + let bad = &legacy_texts(&point_type("", "Hi").replace("10 50 0 Tp", "10 50 /x Tp"))[0]; + assert_eq!(bad.xf, good.xf); +} + +/// Spacing is kept to the ranges the Justification options take, a Minimum, Desired and Maximum +/// out of order go back to their defaults, and hyphenation's letter counts and limit to the ranges +/// `text.setFormat` takes. +#[test] +fn legacy_type_spacing_and_hyphenation_are_kept_in_range() { + let texts = legacy_texts(&point_type("-500 0 900 TC\n0 2000 5000 TW\n1 1 0 40 99 Th", "Hi")); + let p = texts[0].para_at(0); + assert_eq!(p.justification.letter_spacing, [-100.0, 0.0, 500.0]); + assert_eq!(p.justification.word_spacing, [0.0, 1000.0, 1000.0]); + assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (1, 15, 25)); + let texts = legacy_texts(&point_type("5 0 0 TC\n200 100 80 TW", "Hi")); + let j = &texts[0].para_at(0).justification; + let d = vectorcraft_doc::text::JustificationOptions::default(); + assert_eq!((j.letter_spacing, j.word_spacing), (d.letter_spacing, d.word_spacing)); +} + +/// A file's legacy type is read up to its allowance of characters, a single long run included. +#[test] +fn one_long_run_of_legacy_type_stops_at_the_allowance() { + // The reader's allowance, `MAX_CHARS`. + const ALLOWANCE: usize = 1 << 22; + let long = "a".repeat(ALLOWANCE + 10); + let texts = legacy_texts(&point_type("", &long)); + assert_eq!(texts[0].plain_text().chars().count(), ALLOWANCE); +} diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index 7543e5158..f83475638 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -147,6 +147,17 @@ fn text_with_hyphenation_and_tab_attributes_opens_instead_of_failing() { let r = import(src.as_bytes()).unwrap(); assert!(!r.preview, "read through the layers, not a fallback preview"); assert!(!r.warnings.iter().any(|w| w.contains("doesn't know") || w.contains("couldn't be read")), "{:?}", r.warnings); + // `Th` sets the paragraph's hyphenation: off, 2 letters before and after a hyphen, and the + // limit of 99 hyphens in a row kept to the 25 `text.setFormat` takes. + let mut texts = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push(t.para_at(0).clone()); + } + }); + 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)); } /// 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 1f2f14bd8..98260d60a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -81,7 +81,8 @@ private editing data. VectorCraft: 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` - without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868. + without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868, with its paragraphs' letter + and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`). - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files.