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..92c9a4645 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1183,6 +1183,113 @@ fn text_objects_are_slots_named_after_their_story() { assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7)))); } +/// A file in the legacy format (version 7) whose layer holds `art`, crop marks 0 0 200 100. +fn type_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(type_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); +} + #[test] fn what_it_doesnt_read_is_an_error_where_it_shows() { let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err(); 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.