.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.
This commit is contained in:
dotCooCoo
2026-11-02 12:03:33 +07:00
committed by bflatastic
parent 06d037c1b2
commit db11446502
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
@@ -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<vectorcraft_doc::TextObject> {
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<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);
}
@@ -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.