Merge pull request #5125 from dotCooCoo/fix/ai-os22-g11

.ai import: legacy type keeps its spacing, hanging punctuation and hyphenation
This commit is contained in:
BFlat
2026-11-02 12:22:35 +07:00
committed by GitHub
6 changed files with 258 additions and 44 deletions
+7
View File
@@ -3310,6 +3310,13 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"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",
+124 -34
View File
@@ -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<f64> {
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<const N: usize>(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<bool> {
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<f64> = 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();
+7 -9
View File
@@ -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() => {
+107
View File
@@ -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<vectorcraft_doc::TextObject> {
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<vectorcraft_doc::TextObject> {
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();
@@ -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):
+2 -1
View File
@@ -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.