mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 03:48:01 +00:00
Merge pull request #5811 from dotCooCoo/fix/ai-legacy-tab-stops
.ai import: legacy type keeps its tab stops
This commit is contained in:
@@ -4107,6 +4107,14 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"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",
|
||||
|
||||
@@ -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<Option<char>>, bool)>,
|
||||
/// The font `Tf` set last, as the file names it.
|
||||
font: Option<String>,
|
||||
/// Between `Xb` and `XB`: the tab stops of the tab definition read so far.
|
||||
tabs: Option<Vec<TabStop>>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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<f64> = 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::<Vec<f64>>();
|
||||
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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user