diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index a45c203d75..0c73b65092 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -2,8 +2,9 @@ //! //! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes, //! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument` -//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object -//! in a layer is only a stub naming its story in it (`/StoryIndex`). +//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys (names in files +//! saved for version 11, and for keys newer than the version a file is saved for: see [`names`]). +//! A text object in a layer is only a stub naming its story in it (`/StoryIndex`). //! //! The format isn't documented. Most of what is read here was worked out from the files of the //! project's users, used locally and never committed, by comparing it with the type their pages @@ -32,6 +33,8 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; +mod names; + /// Longest story read (bytes). pub(crate) const MAX_STORY: usize = 1 << 20; /// Biggest text document read (bytes, decoded). @@ -631,7 +634,7 @@ impl Texts { if bytes.len() > MAX_BYTES { return None; } - let doc = read(&bytes)?; + let doc = names::numbered(read(&bytes)?); let fonts = doc .get("0")? .get("1")? diff --git a/crates/eps/src/import/ate/names.rs b/crates/eps/src/import/ate/names.rs new file mode 100644 index 0000000000..614be3f227 --- /dev/null +++ b/crates/eps/src/import/ate/names.rs @@ -0,0 +1,355 @@ +//! The keys of a text document written by name. +//! +//! Files saved for version 11 write every key of the text document by its name +//! (`/DocumentResources << /FontSet << /Resources [ ...`) and a typed node's kind under +//! `/StreamTag`, with the long names of the layout's strikes (`/FrameStrike`, `/LineStrike`). Later +//! versions write each key as its number (`/0 << /1 << /0 [ ...`, the kind under `/99`, `/F`, +//! `/L`), except the keys that are newer than the version the file is saved for, which they still +//! write by name (`/ParagraphDirection`, `/VersionString`). A key's number depends on the +//! dictionary it is in (`Leading` is 5 in a character style and 11 in a line), so the names are +//! read with the dictionary each one sits in, from the root down. +//! +//! A public reader of the format lists the names and numbers of the document's resources and +//! objects, the character and paragraph features, a story's model and view, a frame's data, the +//! line and glyph strikes, a font record's name, type and axes, and a color's type and values: +//! . +//! The other names here (the sets' lists, a resource and its kind, a font record's version, the +//! styles' name, parent and features, a Kinsoku set's name and data, a frame's point, curve, first +//! baseline and path options, the runs and their kerning, a strike's kind and origin, the tab stops +//! and the paints) are VectorCraft's own reading of the format, matched to the numbers the reader +//! already uses. +//! +//! Only the keys the text document's reader looks at, and the dictionaries on the way to them, are +//! given numbers; any other key keeps its name and is skipped as before. A key that the dictionary +//! also has as a number keeps the number's value. + +use std::collections::BTreeMap; + +use super::Val; + +/// The kind of a dictionary of the text document, which gives its keys' numbers. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Root, + Resources, + Objects, + FontSet, + FontEntry, + Font, + FontRecord, + KinsokuSet, + KinsokuEntry, + Kinsoku, + CharSheetSet, + CharSheetEntry, + CharSheet, + ParaSheetSet, + ParaSheetEntry, + ParaSheet, + FrameSet, + FrameEntry, + Frame, + Bezier, + FrameData, + FirstBaseline, + PathOptions, + TextObject, + Model, + ParaRuns, + ParaRun, + ParaRunData, + CharRuns, + CharRun, + CharRunData, + KernRuns, + KernRun, + KernRunData, + View, + FrameRef, + Strike, + Transform, + CharFeatures, + ParaFeatures, + TabStops, + TabStop, + Paint, + Color, + /// A dictionary none of whose keys are read: its keys stay as they are. + Other, +} + +/// A key of a dictionary: its name, its number, and the kind of the dictionaries its value holds +/// (the value itself, or the elements of a list). +type Key = (&'static str, &'static str, Kind); + +/// The keys of a set of resources (`FontSet`, `StyleSheetSet`, ...), whose list holds `entry`. +const fn set(entry: Kind) -> [Key; 2] { + [("Resources", "0", entry), ("DisplayList", "1", Kind::Other)] +} + +const FONT_SET: [Key; 2] = set(Kind::FontEntry); +const KINSOKU_SET: [Key; 2] = set(Kind::KinsokuEntry); +const CHAR_SHEET_SET: [Key; 2] = set(Kind::CharSheetEntry); +const PARA_SHEET_SET: [Key; 2] = set(Kind::ParaSheetEntry); +const FRAME_SET: [Key; 2] = set(Kind::FrameEntry); + +/// The character features the reader looks at. +const CHAR_FEATURES: [Key; 20] = [ + ("Font", "0", Kind::Other), + ("FontSize", "1", Kind::Other), + ("AutoLeading", "4", Kind::Other), + ("Leading", "5", Kind::Other), + ("HorizontalScale", "6", Kind::Other), + ("VerticalScale", "7", Kind::Other), + ("Tracking", "8", Kind::Other), + ("BaselineShift", "9", Kind::Other), + ("FontCaps", "12", Kind::Other), + ("BaselineDirection", "35", Kind::Other), + ("LeftAki", "49", Kind::Other), + ("RightAki", "50", Kind::Other), + ("FillColor", "53", Kind::Paint), + ("StrokeColor", "54", Kind::Paint), + ("FillFlag", "56", Kind::Other), + ("StrokeFlag", "57", Kind::Other), + ("LineCap", "61", Kind::Other), + ("LineJoin", "62", Kind::Other), + ("LineWidth", "63", Kind::Other), + ("MiterLimit", "64", Kind::Other), +]; + +/// The paragraph features the reader looks at. +const PARA_FEATURES: [Key; 22] = [ + ("Justification", "0", Kind::Other), + ("FirstLineIndent", "1", Kind::Other), + ("StartIndent", "2", Kind::Other), + ("EndIndent", "3", Kind::Other), + ("SpaceBefore", "4", Kind::Other), + ("SpaceAfter", "5", Kind::Other), + ("AutoLeading", "7", Kind::Other), + ("AutoHyphenate", "9", Kind::Other), + ("HyphenatedWordSize", "10", Kind::Other), + ("PreHyphen", "11", Kind::Other), + ("PostHyphen", "12", Kind::Other), + ("ConsecutiveHyphens", "13", Kind::Other), + ("Zone", "14", Kind::Other), + ("HyphenateCapitalized", "15", Kind::Other), + ("KeepTogether", "23", Kind::Other), + ("BurasagariType", "24", Kind::Other), + ("KinsokuOrder", "25", Kind::Other), + ("Kinsoku", "27", Kind::Other), + ("TabStops", "30", Kind::TabStops), + ("DefaultTabWidth", "31", Kind::Other), + ("DefaultStyle", "32", Kind::Other), + ("ParagraphDirection", "33", Kind::Other), +]; + +/// The keys of a strike of the layout. A glyph strike has no children; its own keys start at 5. Its +/// glyph data (key 21) has no name here: the public list above does not name it, and the files +/// checked do not write it by name. +const STRIKE: [Key; 5] = [ + ("StreamTag", "99", Kind::Other), + ("Transform", "0", Kind::Transform), + ("Bounds", "1", Kind::Other), + ("ChildProcession", "5", Kind::Other), + ("Children", "6", Kind::Strike), +]; +const GLYPH_STRIKE: [Key; 3] = [("StreamTag", "99", Kind::Other), ("Transform", "0", Kind::Transform), ("Bounds", "1", Kind::Other)]; + +/// The long names of the strikes' kinds, with the short ones later versions write. +const STRIKE_KINDS: [(&str, &str); 6] = + [("PathSelectGroupCharacter", "PC"), ("FrameStrike", "F"), ("RowColStrike", "R"), ("LineStrike", "L"), ("Segment", "S"), ("GlyphStrike", "G")]; + +impl Kind { + /// The keys of a dictionary of this kind; `tag` is the kind of typed node it is, for a strike. + fn keys(self, tag: Option<&str>) -> &'static [Key] { + use Kind as K; + match self { + K::Root => &[("DocumentResources", "0", K::Resources), ("DocumentObjects", "1", K::Objects)], + K::Resources => &[ + ("FontSet", "1", K::FontSet), + ("KinsokuSet", "4", K::KinsokuSet), + ("StyleSheetSet", "5", K::CharSheetSet), + ("ParagraphSheetSet", "6", K::ParaSheetSet), + ("TextFrameSet", "8", K::FrameSet), + ], + K::Objects => &[ + ("DocumentSettings", "0", K::Other), + ("TextObjects", "1", K::TextObject), + ("OriginalNormalStyleFeatures", "2", K::CharFeatures), + ("OriginalNormalParagraphFeatures", "3", K::ParaFeatures), + ], + K::FontSet => &FONT_SET, + K::FontEntry => &[("Resource", "0", K::Font)], + K::Font => &[("StreamTag", "99", K::Other), ("Identifier", "0", K::FontRecord)], + K::FontRecord => &[("Name", "0", K::Other), ("Type", "2", K::Other), ("MMAxis", "4", K::Other), ("VersionString", "5", K::Other)], + K::KinsokuSet => &KINSOKU_SET, + K::KinsokuEntry => &[("Resource", "0", K::Kinsoku)], + K::Kinsoku => &[("Name", "0", K::Other), ("Data", "5", K::Other)], + K::CharSheetSet => &CHAR_SHEET_SET, + K::CharSheetEntry => &[("Resource", "0", K::CharSheet)], + K::CharSheet => &[("Name", "0", K::Other), ("Parent", "5", K::Other), ("Features", "6", K::CharFeatures)], + K::ParaSheetSet => &PARA_SHEET_SET, + K::ParaSheetEntry => &[("Resource", "0", K::ParaSheet)], + K::ParaSheet => &[("Name", "0", K::Other), ("Features", "5", K::ParaFeatures), ("Parent", "6", K::Other)], + K::FrameSet => &FRAME_SET, + K::FrameEntry => &[("Resource", "0", K::Frame)], + K::Frame => &[("Point", "0", K::Other), ("Bezier", "1", K::Bezier), ("Data", "2", K::FrameData)], + K::Bezier => &[("Points", "0", K::Other)], + K::FrameData => &[ + ("Type", "0", K::Other), + ("LineOrientation", "1", K::Other), + ("FrameMatrix", "2", K::Other), + ("RowCount", "3", K::Other), + ("ColumnCount", "4", K::Other), + ("TextOnPathTRange", "6", K::Other), + ("RowGutter", "7", K::Other), + ("ColumnGutter", "8", K::Other), + ("FirstBaselineAlignment", "10", K::FirstBaseline), + ("PathData", "11", K::PathOptions), + ], + K::FirstBaseline => &[("Flag", "0", K::Other), ("Min", "1", K::Other)], + K::PathOptions => &[("Flip", "0", K::Other), ("Effect", "1", K::Other), ("Alignment", "2", K::Other), ("CS3Behavior", "6", K::Other)], + K::TextObject => &[("Model", "0", K::Model), ("View", "1", K::View)], + K::Model => &[("Text", "0", K::Other), ("ParagraphRun", "5", K::ParaRuns), ("StyleRun", "6", K::CharRuns), ("KernRun", "8", K::KernRuns)], + K::ParaRuns => &[("RunArray", "0", K::ParaRun)], + K::ParaRun => &[("RunData", "0", K::ParaRunData), ("Length", "1", K::Other)], + K::ParaRunData => &[("ParagraphSheet", "0", K::ParaSheet)], + K::CharRuns => &[("RunArray", "0", K::CharRun)], + K::CharRun => &[("RunData", "0", K::CharRunData), ("Length", "1", K::Other)], + K::CharRunData => &[("StyleSheet", "0", K::CharSheet)], + K::KernRuns => &[("RunArray", "0", K::KernRun)], + K::KernRun => &[("RunData", "0", K::KernRunData), ("Length", "1", K::Other)], + K::KernRunData => &[("ManualKern", "0", K::Other)], + K::View => &[("Frames", "0", K::FrameRef), ("Strikes", "2", K::Strike)], + K::FrameRef => &[("Resource", "0", K::Other)], + K::Strike if matches!(tag, Some("GlyphStrike" | "G")) => &GLYPH_STRIKE, + K::Strike => &STRIKE, + K::Transform => &[("Origin", "0", K::Other)], + K::CharFeatures => &CHAR_FEATURES, + K::ParaFeatures => &PARA_FEATURES, + K::TabStops => &[("TabStops", "0", K::TabStop)], + K::TabStop => &[("TabAdvance", "0", K::Other), ("TabType", "1", K::Other)], + K::Paint => &[("StreamTag", "99", K::Other), ("Color", "0", K::Color), ("CAIKnownStyleID", "5", K::Other)], + K::Color => &[("Type", "0", K::Other), ("Values", "1", K::Other)], + K::Other => &[], + } + } +} + +/// The text document `doc` (its top-level keys) with the keys it writes by name given their +/// numbers, as later versions write them. +pub(super) fn numbered(doc: Val) -> Val { + value(doc, Kind::Root) +} + +/// `v`, whose dictionaries are of kind `kind`, with its keys numbered. +fn value(v: Val, kind: Kind) -> Val { + match v { + Val::Dict(d) if kind != Kind::Other => Val::Dict(dict(d, kind)), + Val::List(l) if kind != Kind::Other => Val::List(l.into_iter().map(|x| value(x, kind)).collect()), + v => v, + } +} + +/// A run's style written by name as the number `n` of a paragraph style (`kind` `ParaSheet`) or a +/// character style (`CharSheet`) of the document: a style with nothing of its own, based on that +/// one. +fn based_on(kind: Kind, n: f64) -> Option { + let parent = match kind { + Kind::ParaSheet => "6", + Kind::CharSheet => "5", + _ => return None, + }; + Some(Val::Dict(BTreeMap::from([(parent.to_string(), Val::Num(n))]))) +} + +/// The entries of a dictionary of kind `kind` with their keys numbered. +fn dict(d: BTreeMap, kind: Kind) -> BTreeMap { + let tag = match d.get("StreamTag").or_else(|| d.get("99")) { + Some(Val::Name(n)) => Some(n.clone()), + _ => None, + }; + let keys = kind.keys(tag.as_deref()); + let mut out = BTreeMap::new(); + let mut named = vec![]; + for (k, v) in d { + match keys.iter().find(|(name, number, _)| *name == k || *number == k) { + Some((name, number, child)) => { + let v = match (*number, v) { + ("99", Val::Name(n)) => Val::Name(STRIKE_KINDS.iter().find(|(long, _)| *long == n).map_or(n, |(_, short)| short.to_string())), + (_, Val::Num(n)) if k == *name => based_on(*child, n).unwrap_or(Val::Num(n)), + (_, v) => value(v, *child), + }; + if k == *name { + named.push((number.to_string(), v)); + } else { + out.insert(k, v); + } + } + None => { + out.insert(k, v); + } + } + } + for (k, v) in named { + out.entry(k).or_insert(v); + } + out +} + +#[cfg(test)] +mod tests { + use super::super::read; + use super::*; + + fn parse(s: &str) -> Val { + read(s.as_bytes()).unwrap_or_else(|| panic!("{s}")) + } + + #[test] + fn named_keys_take_the_numbers_of_the_dictionary_they_are_in() { + // `Leading` is 5 in a character style and 11 in a line; `Name` is 0 in a style and in a font + // record. + let named = parse( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Serif) \ + /VersionString (Version 2) >> >> >> ] >> /StyleSheetSet << /Resources [ << /Resource << /Name (Red) /Parent 0 \ + /Features << /Leading 18.0 /FillColor << /StreamTag /SimplePaint /Color << /Type 1 /Values [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \ + /DocumentObjects << /TextObjects [ << /Model << /Text (Hi) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet 1 >> /Length 2 >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet << /Parent 0 /Features << /FontSize 9.0 >> >> >> /Length 2 >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /FrameStrike /Transform << /Origin [ 1.0 2.0 ] >> \ + /Children [ << /StreamTag /LineStrike /Leading 14.4 /Children [ << /StreamTag /GlyphStrike /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>", + ); + let numbered_form = parse( + "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) /5 (Version 2) >> >> >> ] >> /5 << /0 [ << /0 << /0 (Red) /5 0 \ + /6 << /5 18.0 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \ + /1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 << /6 1 >> >> /1 2 >> ] >> \ + /6 << /0 [ << /0 << /0 << /5 0 /6 << /1 9.0 >> >> >> /1 2 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> \ + /6 [ << /99 /L /Leading 14.4 /6 [ << /99 /G /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>", + ); + // A character style's `Leading` is its key 5; a line's and a glyph's keys the reader doesn't + // look at keep their names. + assert_eq!(numbered(named), numbered_form); + } + + #[test] + fn a_number_wins_over_the_same_key_by_name() { + let doc = numbered(parse("/1 << /3 << /33 1 /ParagraphDirection 0 /Justification 2 >> >>")); + assert_eq!(doc, parse("/1 << /3 << /33 1 /0 2 >> >>")); + } + + #[test] + fn keys_of_dictionaries_no_reader_looks_at_keep_their_names() { + let doc = numbered(parse("/DocumentObjects << /DocumentSettings << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>")); + assert_eq!(doc, parse("/1 << /0 << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>")); + } + + #[test] + fn a_numbered_document_stays_as_it_is() { + let s = "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) >> >> >> ] >> >> \ + /1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 1 >> /1 2 >> ] >> /6 << /0 [ << /0 << /0 0 >> /1 2 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> >> ] >> ] >> >> ] /2 << /1 12.0 >> >>"; + // A run's style written as a number under its key's number stays a number. + assert_eq!(numbered(parse(s)), parse(s)); + } +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 738f02d857..407ca72e9a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3169,6 +3169,29 @@ fn text_document(stories: &[Story<'_>]) -> String { ) } +/// [`text_document`] with its keys written by name, as files saved for version 11 write them: a +/// typed node's kind under `/StreamTag`, the strikes' kinds by their long names. +fn named_text_document(stories: &[Story<'_>]) -> String { + let story = |(text, align, anchor, _t, start): &Story<'_>| { + let n = text.chars().count(); + format!( + "<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet << /Name () /Features << /Justification {align} >> /Parent 0 >> >> /Length {n} >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet << /Name () /Parent 0 /Features << /Font 0 /FontSize 14.0 /FillColor << /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /Length {n} >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike /Transform << /Origin [ {} {} ] >> \ + /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /LineStrike /Children [ \ + << /StreamTag /Segment /Transform << /Origin [ {start} 0.0 ] >> /Mapping << /CharacterCount {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>", + anchor.0, anchor.1 + ) + }; + let frame = |(_, _, _, t, _): &Story<'_>| format!("<< /Resource << /Point [ 0 0 ] /Data << /FrameMatrix [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1); + format!( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \ + /TextFrameSet << /Resources [ {} ] >> >>\n/DocumentObjects << /TextObjects [ {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n", + stories.iter().map(frame).collect::>().join(" "), + stories.iter().map(story).collect::>().join(" ") + ) +} + /// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is /// centred at (50, 50). fn native_with_text(layers: &str, document: &str) -> String { @@ -3319,6 +3342,8 @@ fn type_from_the_text_document_takes_the_version_its_font_record_keeps() { // A record whose name differs from the copies' in punctuation alone still picks its version. assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1"); assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0"); + // Files saved for versions older than the key write it by name among the numbered ones. + assert_eq!(version("/0 (VertestInk-Regular) /VersionString (1.1)"), "1.1"); } /// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character @@ -3728,6 +3753,81 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings); } +#[test] +fn a_text_document_with_named_keys_gives_type_off_the_page_too() { + // Files saved for version 11 write the text document's keys by name. Story 1 is off the page: + // only the text document has it, and story 0 stays in its own text object. + let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]; + let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &named_text_document(&stories)); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; + let r = import(&eps(page, &art)).unwrap(); + let texts = text_of(&r.document.layers[0]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi there", "Away"], "{:?}", r.warnings); + let x = |t: &vectorcraft_doc::TextObject| t.xf.as_coeffs()[4]; + assert!((x(texts[0]) - 58.5).abs() < 1e-6 && (x(texts[1]) - 158.5).abs() < 1e-6, "{:?} {:?}", texts[0].xf, texts[1].xf); +} + +#[test] +fn a_text_document_with_named_keys_reads_as_the_same_one_with_numbers() { + // Type on a hidden layer comes only from the text document: its styles, paints, frames and + // layout read the same whichever way its keys are written. + let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]; + let spare = |doc: &str| { + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts: Vec = text_of(&r.document.layers[1]).into_iter().cloned().collect(); + (texts, r.warnings) + }; + let (numbered, _) = spare(&text_document(&stories)); + let (named, warnings) = spare(&named_text_document(&stories)); + assert_eq!(numbered.len(), 2); + assert_eq!(named, numbered, "{warnings:?}"); + assert_eq!(named[1].para.justify, vectorcraft_doc::Justify::Center); + let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(named[0].runs[0].style.fill, magenta); +} + +#[test] +fn a_text_document_with_named_keys_takes_a_runs_style_by_its_number() { + // Files saved for version 11 name a run's paragraph and character styles by their numbers in the + // document's style sets, and give point type a curve of four equal points. Styles 1 center the + // paragraph and paint the type magenta; paragraph style 2 sets its direction right to left. + let magenta = "<< /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >>"; + let story = |text: &str, para: u8, chars: u8, x: f64| { + let n = text.chars().count(); + format!( + "<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet {para} >> /Length {n} >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet {chars} >> /Length {n} >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike \ + /Transform << /Origin [ {x} 8180.0 ] >> /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ \ + << /StreamTag /LineStrike /Children [ << /StreamTag /Segment /Transform << /Origin [ 0.0 0.0 ] >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>" + ) + }; + let doc = format!( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \ + /StyleSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Font 0 /FontSize 14.0 >> >> >> \ + << /Resource << /Name (Magenta) /Parent 0 /Features << /FillColor {magenta} >> >> >> ] >> \ + /ParagraphSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Justification 0 >> >> >> \ + << /Resource << /Name (Centered) /Parent 0 /Features << /Justification 2 >> >> >> \ + << /Resource << /Name (Leftward) /Parent 0 /Features << /ParagraphDirection 1 >> >> >> ] >> \ + /TextFrameSet << /Resources [ << /Resource << /Bezier << /Points [ 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 ] >> \ + /Data << /Type 0 /FrameMatrix [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\ + /DocumentObjects << /TextObjects [ {} {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n", + story("Mid\r", 1, 1, 8200.0), + story("Back\r", 2, 0, 8300.0) + ); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Mid", "Back"], "{:?}", r.warnings); + assert_eq!(texts[0].para.justify, vectorcraft_doc::Justify::Center); + let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(texts[0].runs[0].style.fill, magenta); + assert_eq!(texts[1].para.direction, Some(vectorcraft_doc::ParaDirection::RightToLeft)); + assert_eq!(texts[1].para.justify, vectorcraft_doc::Justify::Right); + assert_ne!(texts[1].runs[0].style.fill, magenta); +} + #[test] fn a_text_object_written_as_comments_is_read_like_any_other() { let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb9333e9b3..c241c462d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1762,6 +1762,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS symbol library, whose editing data holds symbol definitions and their list but no layers, opens with those symbols in list order, each with its registration point. Other Library… in the Symbols panel's Symbol Libraries Menu imports the symbols of the file you pick ([Create or import symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)) and lists such a library's symbols in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the symbols defined before its layers (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ai/tests.rs`, `cmd::fileio::tests_aiimport::a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols`). In a large canvas `.ai` or EPS ([Large canvas FAQ](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/large-canvas-faq.html)), or a `.ai` whose page is in larger units, opened from its editing data, VectorCraft reads the symbol definitions in the same units as the layers. When the file opens at its full size, each symbol's art, strokes and effects are scaled with the layers, and each instance keeps its own scale and rotation and draws where and as large as before. A new instance comes in at the symbol's full size, the size of an instance at 100% in the file. A symbol library from such a file comes in at the same size, and opacity masks on the layers that don't move with their object are scaled with the layers too. Editing data that VectorCraft wrote keeps its symbols and those masks at their own size, as its `.ai` save writes them (`tests_native::a_large_canvas_eps_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size`, `a_symbol_library_in_larger_units_comes_in_at_the_pages_size`, `build::candidate_tests::scaling_the_symbols_keeps_their_instances_in_place`, `scaling_a_read_document_scales_unlinked_opacity_masks`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). + `.ai` and EPS editing-data import reads a text document whose keys are written by name, as files saved for version 11 write them, and the keys that later files write by name among the numbered ones (a font record's version, a paragraph's direction). The type of such a file comes from its text document, so type outside the artboards opens and each text object keeps its own story A run that names its paragraph or character style by the style's number takes that style (`a_text_document_with_named_keys_gives_type_off_the_page_too`, `a_text_document_with_named_keys_takes_a_runs_style_by_its_number`, `crates/eps/src/import/ate/names.rs`). In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`). SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them.