From bb50d438c85fd3e32a7771cca1e543118701c55e Mon Sep 17 00:00:00 2001 From: jmverges Date: Thu, 12 Nov 2026 21:23:26 +0000 Subject: [PATCH] .ai import: type from the text document takes the face of its exact PostScript name and recorded version (#6983) Type read from a text document's font record was set in whichever installed face first answered to the record's PostScript name (ignoring case and punctuation), and its family was then swapped for any available family spelled alike but for case and punctuation (`FontDb::find_family`): a record naming `SampleInk`, the PostScript name of `Sample/Ink`, set its type in `SAMPLE INK`. Copies of one family and style under other PostScript names or versions were not told apart either, so the type could set at other widths than the file's own page. - `named_font` / `document_font` (crates/eps/src/import/text.rs) give the family and style of the installed face of exactly that PostScript name, else one that differs only in case or punctuation, and name its version when the family and style alone resolve to another installed copy. - The font record's key 5, its version string (name ID 5), is read and picks that version among the faces of the name. Each record is resolved once. - `FontDb::postscript_faces` lists every installed face a PostScript name answers to, loading their families; the import prefers among them one of exactly that name. - Type a PostScript program sets (EPS import) and legacy .ai type take the face of their font's name the same way. - The .ai save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), keeps the version in the style it compares, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes), so such type round-trips without a note. Measured on 224 users' files (used locally, never committed): 16 files set some type in another face; 12 render differently against their own page, 10 closer and 2 further by 0.03% of the page; envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens another way. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/ai_editing/tests.rs | 122 +++++++++++++++++++++- crates/eps/src/ai_editing/writer/texts.rs | 50 +++++---- crates/eps/src/import.rs | 3 +- crates/eps/src/import/ai/legacy_text.rs | 10 +- crates/eps/src/import/ai/tests.rs | 22 ++++ crates/eps/src/import/ate.rs | 65 ++++++++++-- crates/eps/src/import/tests_fontnames.rs | 34 ++++++ crates/eps/src/import/tests_native.rs | 67 ++++++++++++ crates/eps/src/import/text.rs | 79 +++++++++++--- crates/text/src/fontdb/lookup.rs | 30 ++++++ crates/text/src/test_fonts.rs | 24 +++++ crates/text/src/tests/versions.rs | 23 ++++ docs/file-format-parity.md | 7 +- 13 files changed, 487 insertions(+), 49 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 43d93cec25..b7270f614e 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4423,7 +4423,7 @@ fn type_attributes_the_data_cant_hold_are_reported() { let back = read_back(&doc); assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Text(_))); // Any other attribute the text document doesn't hold is reported too: No Break, a paragraph's - // left-to-right direction set on it, Optical Margin Alignment, a font version, a font that isn't + // left-to-right direction set on it, Optical Margin Alignment, a font that isn't // installed whose name doesn't come back (a hyphen in its family), a Lab color, // small capitals at another size, right-to-left letters aligned to the start of a paragraph // without a direction of its own, tracking that isn't a whole number. @@ -4432,7 +4432,6 @@ fn type_attributes_the_data_cant_hold_are_reported() { |t| t.runs[0].style.tracking = 12.5, |t| t.para.direction = Some(vectorcraft_doc::ParaDirection::LeftToRight), |t| t.optical_margin = true, - |t| t.runs[0].style.font_version = Some("2.0".into()), |t| t.runs[0].style.font_family = "my-missing font".into(), |t| t.runs[0].style.fill = Paint::solid(MINT), |t| t.runs[0].style.small_caps = Some(80.0), @@ -4455,11 +4454,130 @@ fn type_attributes_the_data_cant_hold_are_reported() { missing.runs[0].style.font_family = "Missing Sans Probe".into(); missing.runs[0].style.font_style = "Bold Italic".into(); assert!(with(missing).is_empty()); + // A version that isn't installed draws in the face the font resolves to, and comes back in it. + let mut uninstalled = t.clone(); + uninstalled.runs[0].style.font_version = Some("2.0".into()); + assert!(with(uninstalled).is_empty()); edit(&mut t); assert_eq!(with(t), [writer::TYPE_ATTRIBUTES]); } } +/// Installs the copies of each font `copies` lists, the first of each loaded first. +fn install_copies(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) { + for (inter, names) in copies { + let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap(); + assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}"); + } +} + +/// A run's text, font family and font version, as [`fonts_saved`] reads them back. +type SavedRun = (String, String, Option); + +/// A 200 × 100 document of point type whose runs are `runs` (text, family, version). +fn runs_doc(runs: &[(&str, &str, Option<&str>)]) -> Document { + use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "", style(18.0, Color::BLACK)); + t.runs = runs + .iter() + .map(|(text, family, version)| { + let st = CharStyle { font_family: (*family).into(), font_version: version.map(Into::into), ..style(18.0, Color::BLACK) }; + TextRun::new(*text, st) + }) + .collect(); + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc +} + +/// [`runs_doc`] saved and read back: the save's warnings, and the runs read back. +fn fonts_saved(runs: &[(&str, &str, Option<&str>)]) -> (Vec, Vec) { + let doc = runs_doc(runs); + let warnings = export_ai_editing(&doc, &opts()).unwrap().warnings; + let back = read_back(&doc); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type comes back as type") }; + (warnings, t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.font_version.clone())).collect()) +} + +/// How many fonts the font set of [`runs_doc`] names, the hidden characters' font aside. +fn fonts_written(runs: &[(&str, &str, Option<&str>)]) -> usize { + text_document(&program(&runs_doc(runs))).matches("/CoolTypeFont").count() - 1 +} + +/// Type in a font installed beside a family spelled alike but for case and punctuation +/// (`Sample/Ink`, PostScript name `SampleInk`, beside `SAMPLE INK`), or beside a copy whose +/// PostScript name differs only in case (the type naming its version), is written under its +/// PostScript name and read back in its own family and version, so nothing about its font is +/// reported lost. +#[test] +fn type_in_a_font_beside_a_family_spelled_alike_keeps_its_font() { + install_copies(&[ + (true, ("SAVETEST INK", "Regular", "Version 1.000", "SAVETESTINK-Regular")), + (false, ("Savetest/Ink", "Regular", "Version 1.000", "SavetestInk")), + (true, ("CASESAVE INK", "Regular", "Version 1.000", "CASESAVEINK")), + (false, ("Casesave Ink", "Regular", "Version 2.000", "CasesaveInk")), + ]); + let (warnings, back) = fonts_saved(&[("Kept", "Savetest/Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Savetest/Ink".to_string(), None)]); + let (warnings, back) = fonts_saved(&[("Kept", "Casesave Ink", Some("Version 2.000"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Casesave Ink".to_string(), Some("Version 2.000".to_string()))]); +} + +/// Type in a static font's version that the family and style don't resolve to (two installed +/// copies of one PostScript name, family and style) is written with that version (`/5`) and comes +/// back in it, without a note; two runs in two versions are two fonts of the set. +#[test] +fn type_in_another_version_of_a_static_font_keeps_it() { + install_copies(&[(true, ("Rttest Ink", "Regular", "1.0", "RttestInk-Regular")), (false, ("Rttest Ink", "Regular", "1.1", "RttestInk-Regular"))]); + let (warnings, back) = fonts_saved(&[("Kept", "Rttest Ink", Some("1.1"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]); + let (warnings, back) = fonts_saved(&[("Old", "Rttest Ink", None), ("New", "Rttest Ink", Some("1.1"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Old".to_string(), "Rttest Ink".to_string(), None), ("New".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]); + // A run naming the version its family and style resolve to anyway, or one that isn't + // installed, is in the same face as a run naming none: one font of the set, and no note. + let (warnings, back) = fonts_saved(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(back.iter().all(|(_, family, version)| family == "Rttest Ink" && version.is_none()), "{back:?}"); + assert_eq!(fonts_written(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]), 1); +} + +/// Type in the copy of a font that its family names, beside a copy of the same PostScript name in +/// another family that the name alone gives first, is written with its version (`/5`), so it comes +/// back in its own copy and family, without a note. +#[test] +fn type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it() { + install_copies(&[ + (true, ("RvdupsaveInk", "Regular", "2.0", "RvdupsaveInk-Regular")), + (false, ("Rvdupsave Ink", "Regular", "1.0", "RvdupsaveInk-Regular")), + ]); + let (warnings, back) = fonts_saved(&[("Kept", "Rvdupsave Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.iter().map(|r| r.1.as_str()).collect::>(), ["Rvdupsave Ink"]); + let (warnings, back) = fonts_saved(&[("Kept", "RvdupsaveInk", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.iter().map(|r| r.1.as_str()).collect::>(), ["RvdupsaveInk"]); +} + +/// A version string longer than the reader takes (256 bytes) isn't one it can pick a copy by: type +/// in such a copy, which its name alone doesn't give back, is reported as losing its font. +#[test] +fn a_version_longer_than_the_reader_takes_is_reported() { + let long = "9".repeat(300); + install_copies(&[ + (true, ("Longtest Ink", "Regular", "1.0", "LongtestInk-Regular")), + (false, ("Longtest Ink", "Regular", &long, "LongtestInk-Regular")), + ]); + let (warnings, back) = fonts_saved(&[("Long", "Longtest Ink", Some(&long))]); + assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]); + assert_eq!(back, [("Long".to_string(), "Longtest Ink".to_string(), None)]); +} + /// On a large canvas, area type, threaded frames and type on a path stay type: the frames and /// lengths are written at the canvas's scale. Frames of a thread at two scales, and area type /// scaled by two factors, are written as outlines. diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1e47ca1564..b161ec636f 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -338,6 +338,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { CharStyle { font_family: s.font_family.clone(), font_style: s.font_style.clone(), + // The font record's version (`/5`) names the face; whether it comes back is `font_kept`'s. + font_version: s.font_version.clone(), size: s.size, leading: s.leading, tracking: s.tracking.round(), @@ -452,15 +454,17 @@ struct FontRecord { /// the font's order, in the axis's own units. Empty for a static font, which has none: without /// it, an app that opens the file takes a variable font's default instance (#6388). design: Vec, - /// For a variable font, its version string (`/5`, name ID 5); empty for a static font. + /// Its version string (`/5`, name ID 5): for a variable font, and for a static font whose + /// PostScript name alone gives the reader another installed copy (two copies of one font, see + /// the reader's `document_font`); else empty. version: String, } -/// The font the font set names for style `st`: an installed face's PostScript name, and for a -/// variable font the setting of each of its axes (the named instance's, or the style's own -/// Variable Font setting, clamped to the axis as layout clamps it) and its version; else its family -/// and style as `family-style` (the family alone for Regular), the form the reader's -/// `family_style` splits. +/// The font the font set names for style `st`: an installed face's PostScript name, its version +/// for a variable font or when the name alone gives the reader another installed copy, and for a +/// variable font the setting of each of its axes (the named instance's, or the style's own Variable +/// Font setting, clamped to the axis as layout clamps it); else its family and style as +/// `family-style` (the family alone for Regular), the form the reader's `family_style` splits. fn font_name(st: &CharStyle) -> FontRecord { let db = vectorcraft_text::FontDb::global(); // The face of that family and style (the font database gives a fallback face for others). @@ -479,8 +483,11 @@ fn font_name(st: &CharStyle) -> FontRecord { set.map_or(own, |v| (v as f32).clamp(a.min.min(a.max), a.max.max(a.min))) }) .collect(); - let version = if design.is_empty() { String::new() } else { face.version.clone() }; - FontRecord { name: face.postscript_name(), truetype: face.is_truetype(), design, version } + let name = face.postscript_name(); + // The reader takes the face of a name alone as `postscript_face` finds it. + let by_name = crate::import::postscript_face(&name, None).is_some_and(|f| f.id() == face.id()); + let version = if design.is_empty() && by_name { String::new() } else { face.version.clone() }; + FontRecord { name, truetype: face.is_truetype(), design, version } } None if st.font_style.is_empty() || st.font_style == "Regular" => record(st.font_family.clone()), None => record(format!("{}-{}", st.font_family, st.font_style.replace(' ', ""))), @@ -518,12 +525,19 @@ fn fixed(v: f32) -> i64 { if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 } } -/// Does the font of style `st` come back as itself: the family and style the reader makes of the -/// name it is written under (as its `char_style` does)? +/// Does the font of style `st` come back as itself: are the family and style the reader makes of +/// the record it is written as (its name, and its version as the reader takes it, as its +/// `char_style` does) the style's own, and the face they and the version read back resolve to the +/// face of the style? A version that isn't installed draws in the face the font resolves to anyway. fn font_kept(st: &CharStyle) -> bool { - let (family, style) = crate::family_style(&font_name(st).name); - let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); - family == st.font_family && style == st.font_style + let record = font_name(st); + let version = crate::import::record_version(&record.version); + let (family, style, read) = crate::import::document_font(&record.name, version); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str, style: &str, version: Option<&str>| db.face_version(family, style, version).map(|f| f.id()); + family == st.font_family + && style == st.font_style + && face(&family, &style, read.as_deref()) == face(&st.font_family, &st.font_style, st.font_version.as_deref()) } /// Whether `c` is a strong right-to-left letter (`Some(true)`: Hebrew, Arabic, Syriac, Thaana, NKo @@ -1022,13 +1036,13 @@ impl TextDoc { .iter() .map(|f| { let mut font = vec![(0, V::Str(f.name.clone())), (2, V::Int(i64::from(f.truetype)))]; - // A variable font's place in its design space and its version (#6388); a static - // font has neither. + // A variable font's place in its design space (#6388), and the version of a + // variable font or of a static one its name alone doesn't give back. if !f.design.is_empty() { font.push((4, V::List(f.design.iter().map(|v| V::Int(fixed(*v))).collect()))); - if !f.version.is_empty() { - font.push((5, V::Str(f.version.clone()))); - } + } + if !f.version.is_empty() { + font.push((5, V::Str(f.version.clone()))); } dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(font))]) }) diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index b750efbc20..2cc4bed521 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -89,10 +89,11 @@ pub(crate) use ai::{ BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; -pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; +pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; pub use text::family_style; +pub(crate) use text::{document_font, postscript_face}; /// The page of a PostScript file without a bounding box (US Letter). const LETTER: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 6cdbb96747..043d6f2e4d 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -508,10 +508,16 @@ impl Reader<'_> { None => (full.as_str(), None), }; st.cmap = cmap; - let (family, style) = super::super::super::text::family_style(name); + // The installed face of that PostScript name, as type from a text document + // takes it; else the name split into a family and style. + let text = super::super::super::text::named_font(name, None); + let (family, style, version) = text.unwrap_or_else(|| { + let (family, style) = super::super::super::text::family_style(name); + (family, style, None) + }); st.style.font_family = family; st.style.font_style = style; - st.style.font_version = None; + st.style.font_version = version; } 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); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c6a6041212..3c0ac39a05 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3930,6 +3930,28 @@ 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") } +/// Legacy type is set in the installed face of exactly the PostScript name its `Tf` gives, as type +/// from a text document is: not a face whose name differs only in case and loads first, nor the +/// copy the family and style resolve to when the name's own is another (it names its version). +#[test] +fn legacy_type_takes_the_face_of_its_exact_postscript_name() { + use vectorcraft_text::test_fonts::named_copy; + let db = vectorcraft_text::FontDb::global(); + for (inter, names) in [ + (true, ("TWINLEGACY INK", "Regular", "Version 1.000", "TWINLEGACYINK-Regular")), + (false, ("Twinlegacy Ink", "Regular", "Version 2.000", "TwinlegacyInk-Regular")), + ] { + assert_eq!(db.add_font(named_copy(inter, names).unwrap()), 1, "{names:?}"); + } + let face = |font: &str| { + let texts = legacy_texts(&point_type("", "Hi").replace("/_Helvetica 12 Tf", &format!("/_{font} 12 Tf"))); + let f = db.style_face(&texts[0].runs[0].style).unwrap(); + (f.postscript_name(), f.version.clone()) + }; + assert_eq!(face("TwinlegacyInk-Regular"), ("TwinlegacyInk-Regular".to_string(), "Version 2.000".to_string())); + assert_eq!(face("TWINLEGACYINK-Regular"), ("TWINLEGACYINK-Regular".to_string(), "Version 1.000".to_string())); +} + /// `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. diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index efaed14199..d421498ff3 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -451,7 +451,8 @@ fn spend_tabs(tabs: &mut Vec, budget: &mut usize) -> bool { #[derive(Clone, Debug)] struct Style { - font: Option, + /// The font its record names, as the fonts the app has set it ([`DocFont::font`]). + font: Option, size: f64, /// `None`: auto. leading: Option, @@ -482,10 +483,44 @@ struct Style { standard_vertical_roman: bool, } +/// The longest font version string read from a font record: name ID 5 strings are short (`Version +/// 7.200`, or with the tools that built the font, some 80 characters). +const MAX_FONT_VERSION: usize = 256; + +/// The version a font record's key 5 gives `v`, as the reader takes it: `None` when it is empty or +/// longer than [`MAX_FONT_VERSION`] bytes. The `.ai` save checks a record it writes against this. +pub(crate) fn record_version(v: &str) -> Option<&str> { + (!v.is_empty() && v.len() <= MAX_FONT_VERSION).then_some(v) +} + +/// A font as type is set in it: family, style and the version it names +/// ([`super::text::document_font`]). +type Font = (String, String, Option); + +/// A font of a text document's font list, as its record (a `CoolTypeFont`) names it. +#[derive(Debug, Default)] +struct DocFont { + /// Key 0: the PostScript name. + name: String, + /// Key 5, when the record keeps it: the font's version string (name ID 5), which tells installed + /// copies of one font apart. + version: Option, + /// [`Self::font`]'s answer, found once for every run that names the record. + font: std::sync::OnceLock, +} + +impl DocFont { + /// The font type set in this record's font takes: the installed copy it names + /// ([`super::text::document_font`]). + fn font(&self) -> &Font { + self.font.get_or_init(|| super::text::document_font(&self.name, self.version.as_deref())) + } +} + /// The text document of an editing copy. pub(super) struct Texts { stories: Vec, - fonts: Vec, + fonts: Vec, /// The character and paragraph styles a story starts from. defaults: Val, para_defaults: Val, @@ -544,7 +579,15 @@ impl Texts { .get("0")? .list() .iter() - .map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string()) + .map(|f| { + let record = f.get("0").and_then(|f| f.get("0")); + let text = |k: &str| record.and_then(|r| r.get(k)).and_then(Val::str); + DocFont { + name: text("0").unwrap_or_default().to_string(), + version: text("5").and_then(record_version).map(str::to_string), + font: std::sync::OnceLock::new(), + } + }) .collect(); let main = doc.get("1")?; let sheets = |key: &str| -> Vec { @@ -765,12 +808,12 @@ impl Texts { let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num); let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); - let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); + let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.name.is_empty()); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { - font, + font: font.map(|f| f.font().clone()), size, leading: if on("4") { None } else { num("5").filter(|v| *v > 0.0 && *v < 1e5) }, tracking: num("8").filter(|v| v.abs() < 1e5).unwrap_or(0.0), @@ -1322,13 +1365,12 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } } -/// The character style of character attributes `s`: its font from the fonts the app has, its -/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed -/// in the type's text space by `to_text` (the document → its text space), where they stay as the -/// document has them. +/// The character style of character attributes `s`: its font from the fonts the app has (the +/// installed copy its font record names, [`DocFont::font`]), its lengths (size, leading, baseline +/// shift, stroke width) scaled by `scale`, and its patterns placed in the type's text space by +/// `to_text` (the document → its text space), where they stay as the document has them. fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { - let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); - let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + let (family, style, version) = s.font.clone().unwrap_or_else(|| (CharStyle::default().font_family, "Regular".to_string(), None)); // A fill or stroke that is on takes its pattern in place of its colour. let paint = |color: Option, pattern: &Option| match (color, pattern) { // A pattern that can't be placed (the type's text space out of range) keeps the colour. @@ -1343,6 +1385,7 @@ fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { CharStyle { font_family: family, font_style: style, + font_version: version, size: s.size * scale, // Auto leading stays auto: the layout gives each line the paragraph's share of the largest // type on it, as the file's own layout does ("The largest leading value in a line of type diff --git a/crates/eps/src/import/tests_fontnames.rs b/crates/eps/src/import/tests_fontnames.rs index d925905ee7..a2a8d45641 100644 --- a/crates/eps/src/import/tests_fontnames.rs +++ b/crates/eps/src/import/tests_fontnames.rs @@ -63,3 +63,37 @@ fn abbreviated_styles_in_postscript_names_are_spelled_out() { fn an_installed_face_of_the_postscript_name_names_the_font() { assert_eq!(crate::family_style("ABCDEF+SourceSans3-Semibold"), ("Source Sans 3".to_string(), "Semibold".to_string())); } + +/// The PostScript name and version of the face each `show` of a program that sets `fonts` in turn +/// draws its type in. +fn faces_shown(fonts: &[&str]) -> Vec<(String, String)> { + let body: String = fonts.iter().map(|f| format!("/{f} findfont 12 scalefont setfont 10 20 moveto (Hi) show\n")).collect(); + let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}showpage\n%%EOF\n"); + let r = import(eps.as_bytes()).unwrap(); + let mut faces = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let face = FontDb::global().style_face(&t.runs[0].style).unwrap(); + faces.push((face.postscript_name(), face.version.clone())); + } + }); + faces +} + +/// Type a PostScript program sets is drawn in the installed face of exactly the font's PostScript +/// name, as type from a text document is: not another family spelled alike but for case and +/// punctuation, nor a face whose name differs only in case and loads first. +#[test] +fn shown_type_takes_the_face_of_its_exact_postscript_name() { + use vectorcraft_text::test_fonts::named_copy; + for (inter, names) in [ + (true, ("FUZZSHOW INK", "Regular", "Version 1.000", "FUZZSHOWINK-Regular")), + (false, ("Fuzzshow/Ink", "Regular", "Version 1.000", "FuzzshowInk")), + (true, ("CASESHOW INK", "Regular", "Version 1.000", "CASESHOWINK")), + (false, ("Caseshow Ink", "Regular", "Version 2.000", "CaseshowInk")), + ] { + assert_eq!(FontDb::global().add_font(named_copy(inter, names).unwrap()), 1, "{names:?}"); + } + let names: Vec = faces_shown(&["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]).into_iter().map(|f| f.0).collect(); + assert_eq!(names, ["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 687834c97b..40338adf5b 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3128,6 +3128,73 @@ fn type_from_the_text_document_takes_its_character_styles_attributes() { assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour"); } +/// The PostScript name and version of the face that type from a text document whose font record is +/// `record` (in place of `/0 (Helvetica)`) is set in. +fn face_set_from(record: &str) -> (String, String) { + let doc = text_document(&[("Hamburg\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replacen("/0 (Helvetica)", record, 1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{record}: {:?}", r.warnings); + let face = vectorcraft_text::FontDb::global().style_face(&texts[0].runs[0].style).unwrap(); + (face.postscript_name(), face.version.clone()) +} + +/// Installs the copies of each font `copies` lists, the first of each loaded first. +fn install(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) { + for (inter, names) in copies { + let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap(); + assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}"); + } +} + +/// Type from the text document is set in the installed face of exactly the PostScript name its +/// font record gives, whatever other installed copies answer to that name too: one whose family +/// is spelled alike but for case and punctuation (`SAMPLE INK` beside `Sample/Ink`, PostScript name +/// `SampleInk`), one whose PostScript name differs only in case or punctuation (in its own family or +/// another), or another copy of the same family and style under another PostScript name (an older +/// build of the font: the type names its version). Those copies set the type at other widths. +#[test] +fn type_from_the_text_document_takes_the_face_of_its_exact_postscript_name() { + install(&[ + (true, ("FUZZTEST INK", "Regular", "Version 1.000", "FUZZTESTINK-Regular")), + (false, ("Fuzztest/Ink", "Regular", "Version 1.000", "FuzztestInk")), + (true, ("CASETEST INK", "Regular", "Version 1.000", "CASETESTINK")), + (false, ("Casetest Ink", "Regular", "Version 2.000", "CasetestInk")), + (true, ("Copytest Ink", "Regular", "Version 1.000", "CopytestInk-Light")), + (false, ("Copytest Ink", "Regular", "Version 2.000", "CopytestInk-Regular")), + (true, ("NeuetestInk", "Bold", "Version 1.000", "NeuetestInkBold")), + (false, ("Neuetest Ink", "Bold", "Version 1.000", "NeuetestInk-Bold")), + ]); + assert_eq!(face_set_from("/0 (FuzztestInk)").0, "FuzztestInk", "not the family spelled alike"); + assert_eq!(face_set_from("/0 (FUZZTESTINK-Regular)").0, "FUZZTESTINK-Regular"); + assert_eq!(face_set_from("/0 (CasetestInk)").0, "CasetestInk", "not the name in other case, loaded first"); + assert_eq!(face_set_from("/0 (CASETESTINK)").0, "CASETESTINK"); + assert_eq!(face_set_from("/0 (CopytestInk-Regular)"), ("CopytestInk-Regular".to_string(), "Version 2.000".to_string())); + assert_eq!(face_set_from("/0 (CopytestInk-Light)"), ("CopytestInk-Light".to_string(), "Version 1.000".to_string())); + assert_eq!(face_set_from("/0 (NeuetestInk-Bold)").0, "NeuetestInk-Bold", "not the name without its hyphen, in another family"); + // A name in a case no installed face has still finds the font. + assert_eq!(face_set_from("/0 (casetestink)").0, "CASETESTINK"); +} + +/// A font record may keep the font's version string (key 5, name ID 5): of two installed copies +/// with one PostScript name, family and style, the type is set in that version, also when the +/// record spells the name with other punctuation. A version that isn't installed, or a record +/// without one, gives the copy the family and style resolve to. +#[test] +fn type_from_the_text_document_takes_the_version_its_font_record_keeps() { + install(&[(true, ("Vertest Ink", "Regular", "1.0", "VertestInk-Regular")), (false, ("Vertest Ink", "Regular", "1.1", "VertestInk-Regular"))]); + let version = |record: &str| face_set_from(record).1; + assert_eq!(version("/0 (VertestInk-Regular) /5 (1.1)"), "1.1"); + assert_eq!(version("/0 (VertestInk-Regular) /5 (1.0)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular) /5 (9.9)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular) /5 ()"), "1.0"); + // 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"); +} + /// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character /// Style]` and `[Normal Paragraph Style]` before the text document's base attributes. A Large /// Canvas copy (1:10) writes the styles at the art's scale (1.2 for 12 points, 0.6 for a 6-point diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 989450f440..515e0b12ec 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -42,20 +42,75 @@ fn style_word(w: &str) -> String { .to_string() } +/// A font's PostScript name without a subset prefix (`ABCDEF+`) or the `*1` of a re-encoded copy +/// (`ABCDEF+RollerBabyBV*1` is `RollerBabyBV`). +fn bare_name(name: &str) -> &str { + let name = match name.split_once('+') { + Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => name, + }; + match name.rsplit_once('*') { + Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base, + _ => name, + } +} + +/// The font of type that a text document's font record names: by `name`, its PostScript name, and +/// `version`, the version string (name ID 5) the record keeps, if any. An installed face of that +/// name gives its font ([`named_font`]). Without one, `name` is split ([`family_style`]) and the +/// family spelled as an available family is ([`FontDb::find_family`]: `MicrosoftYaHei` is +/// Microsoft YaHei). → (family, style, version). +/// +/// [`FontDb::find_family`]: vectorcraft_text::FontDb::find_family +pub(crate) fn document_font(name: &str, version: Option<&str>) -> (String, String, Option) { + named_font(name, version).unwrap_or_else(|| { + let (family, style) = family_style(name); + let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + (family, style, None) + }) +} + +/// The font of type set in the installed face of PostScript name `name` (a subset prefix and a +/// re-encoded copy's `*1` aside) in `version` ([`postscript_face`]): that face's own family and +/// style, and its version when the family and style alone resolve to another installed copy of +/// the font. A library can keep two copies under one family and style that differ in their +/// PostScript names or their versions, and draw the same text at different widths. → (family, +/// style, version); `None` when no installed face has the name. +pub(crate) fn named_font(name: &str, version: Option<&str>) -> Option<(String, String, Option)> { + let face = postscript_face(bare_name(name), version)?; + // As the PDF import names a version: only one that the family and style don't resolve to. + let default = vectorcraft_text::FontDb::global().face(&face.family, &face.style); + let version = default.filter(|d| d.version != face.version).map(|_| face.version.clone()); + Some((face.family.clone(), face.style.clone(), version)) +} + +/// The installed face of PostScript name `name` (without a subset prefix), among the installed +/// faces that answer to it ([`FontDb::postscript_faces`]): those of exactly that name when there +/// are any (PostScript names are case-sensitive, so `SampleSans` isn't the face named +/// `SAMPLESANS`, and `SampleSerif-Bold` isn't `SampleSerifBold`), and of those the one in +/// `version` when it is installed, else the first. `None` when no installed face has the name. +/// +/// [`FontDb::postscript_faces`]: vectorcraft_text::FontDb::postscript_faces +pub(crate) fn postscript_face(name: &str, version: Option<&str>) -> Option> { + let db = vectorcraft_text::FontDb::global(); + let found = db.find_postscript(name)?; + if found.postscript_name() == name && version.is_none_or(|v| v == found.version) { + return Some(found); + } + let faces = db.postscript_faces(name); + let named: Vec<_> = faces.iter().filter(|f| f.postscript_name() == name).cloned().collect(); + let pool = if named.is_empty() { faces } else { named }; + let in_version = version.and_then(|v| pool.iter().find(|f| f.version == v).cloned()); + Some(in_version.or_else(|| pool.into_iter().next()).unwrap_or(found)) +} + /// A font's family and style from its PostScript name, without a subset prefix (`ABCDEF+`) or the /// `*1` of a re-encoded copy (`ABCDEF+RollerBabyBV*1`): an installed or bundled face of that /// exact name gives its own (a family can hold hyphens: `rounded-x-mplus-1c-black` is Rounded-X /// M+ 1c, black); else the name is split, `Helvetica-BoldOblique` → (`Helvetica`, `Bold /// Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`). pub fn family_style(name: &str) -> (String, String) { - let name = match name.split_once('+') { - Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest, - _ => name, - }; - let name = match name.rsplit_once('*') { - Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base, - _ => name, - }; + let name = bare_name(name); if let Some(face) = vectorcraft_text::FontDb::global().find_postscript(name) { return (face.family.clone(), face.style.clone()); } @@ -551,11 +606,11 @@ impl Interp<'_> { if !(size.is_finite() && size > 1e-3 && size < 1e5) { return ps_err("undefinedresult", "a font size"); } - let (family, font_style) = family_style(&name); - // The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". - let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // The installed face of that PostScript name, as type from a text document takes it; else + // the available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". + let (font_family, font_style, font_version) = document_font(&name, None); let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; - let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; + let style = CharStyle { font_family, font_style, font_version, size, fill, stroke: Paint::None, ..CharStyle::default() }; let mut t = TextObject::point(Point::ZERO, text, style); t.vertical = vertical; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index fa91e97875..23e00afa0c 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -15,6 +15,9 @@ use super::{ Ordering, Size, }; +/// The most faces [`FontDb::postscript_faces`] gives, and the most families it loads. +const MAX_POSTSCRIPT_FACES: usize = 16; + impl FontDb { /// The family's own name and the style for a name given in a document: any of a family's /// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its @@ -141,6 +144,33 @@ impl FontDb { loaded(self) } + /// Every installed face that [`find_postscript`](Self::find_postscript) could give for `name` + /// (its PostScript name ignoring case and anything but letters and digits), its answer first, + /// then the others in load order, at most 16 in all: copies of one font in + /// other families (`SampleSerif` beside `Sample Serif`) or versions, and names that differ + /// only in case. Each family that has one is loaded from the system fonts whole, as + /// [`versions`](Self::versions) loads one. Empty when no installed font has the name. + pub fn postscript_faces(&self, name: &str) -> Vec> { + let Some(first) = self.find_postscript(name) else { return vec![] }; + let key = norm(name); + let mut families: Vec = vec![first.family.clone()]; + if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + for a in v.iter().filter(|a| a.paired.is_none() && a.style.is_some()) { + if families.len() < MAX_POSTSCRIPT_FACES && !families.iter().any(|f| f.eq_ignore_ascii_case(&a.family)) { + families.push(a.family.clone()); + } + } + } + for family in &families { + self.load_family_files(family); + } + let mut out = vec![first.clone()]; + let faces = self.read_faces(); + let others = faces.iter().filter(|f| f.id != first.id && f.keys.postscript.contains(&key)); + out.extend(others.take(MAX_POSTSCRIPT_FACES - 1).cloned()); + out + } + /// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII /// case). Documents name fonts this way, and a family can hold hyphens /// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split. diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2d766ae571..a75ee8d3af 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1594,6 +1594,30 @@ pub fn twin_font(newer: bool) -> Option> { Some(builder.build()) } +/// The names of a [`named_copy`]: family, style, version string and PostScript name. +pub type CopyNames<'a> = (&'a str, &'a str, &'a str, &'a str); + +/// The bundled Source Sans 3 Regular or, `inter`, the bundled Inter Regular (glyphs of other +/// widths) under `names`, as an installed copy of a font: a font library can keep copies whose +/// names differ only in case or punctuation, or whose family and style are one but whose PostScript +/// names or versions differ. `None` when it can't be built. +pub fn named_copy(inter: bool, (family, style, version, postscript): CopyNames<'_>) -> Option> { + const SOURCE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let font = skrifa::FontRef::new(if inter { INTER } else { SOURCE }).ok()?; + let full = format!("{family} {style}"); + let name = name_table(&[(1, family), (2, style), (4, &full), (5, version), (6, postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`numbered_font`]: styles numbered W3 and W6, and no Regular. pub const NUMBERED_FAMILY: &str = "Numtest Mincho"; diff --git a/crates/text/src/tests/versions.rs b/crates/text/src/tests/versions.rs index a721e435b1..ca28d7e4ef 100644 --- a/crates/text/src/tests/versions.rs +++ b/crates/text/src/tests/versions.rs @@ -41,3 +41,26 @@ fn type_naming_a_version_is_set_in_it() { assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}"); assert_eq!(width(&db, Some(VERSIONS[1])), newer); } + +/// Every installed face a PostScript name answers to, ignoring case and punctuation: the face +/// `find_postscript` gives first, then the copies in other families and the versions, in load +/// order; none for a name no face has. +#[test] +fn every_face_of_a_postscript_name_is_found() { + use crate::test_fonts::named_copy; + let db = FontDb::with_font_dirs(vec![]); + for (inter, names) in [ + (true, ("Facetest Ink", "Bold", "Version 1.000", "FacetestInkBold")), + (false, ("Facetest-Ink", "Bold", "Version 1.000", "FacetestInk-Bold")), + (true, ("Facetest-Ink", "Bold", "Version 2.000", "FACETESTINK-BOLD")), + (false, ("Other Ink", "Bold", "Version 1.000", "OtherInk-Bold")), + ] { + assert_eq!(db.add_font(named_copy(inter, names).expect("the test font builds")), 1, "{names:?}"); + } + let names = |name: &str| db.postscript_faces(name).iter().map(|f| f.postscript_name()).collect::>(); + let first = db.find_postscript("FacetestInk-Bold").expect("a face").postscript_name(); + assert_eq!(first, "FacetestInkBold"); + assert_eq!(names("FacetestInk-Bold"), ["FacetestInkBold", "FacetestInk-Bold", "FACETESTINK-BOLD"]); + assert_eq!(names("OtherInk-Bold"), ["OtherInk-Bold"]); + assert!(names("NoSuchInk-Bold").is_empty()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 20cce4636a..cf6e0a8a09 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -59,7 +59,7 @@ private editing data. VectorCraft: and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file - doesn't define, or a style that isn't a pattern, keeps the colour as before. Area type of one + doesn't define, or a style that isn't a pattern, keeps the colour as before. Its font is an installed face of the PostScript name its font record gives: one of exactly that name, case and punctuation included (`SampleSans` isn't the face named `SAMPLESANS`, nor `SampleSerif-Bold` the one named `SampleSerifBold`), when one is installed, else one whose name differs only in them. The type takes that face's own family and style, not another family spelled alike (`SAMPLE INK` beside `Sample/Ink`, whose PostScript name is `SampleInk`). Among those faces the record's version (key 5, name ID 5) picks the one in that version when it is installed; a version that isn't installed gives the first, without a note. The type names the face's version when its family and style alone resolve to another installed copy. Copies that share their family, style and version (an OpenType and a TrueType build of one release) can't be told apart: only the first of them loads. Type that a PostScript program sets, and legacy `.ai` type, take the face of their font's name the same way, with no version to read. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -132,7 +132,7 @@ private editing data. VectorCraft: and paragraph attributes are left out with a warning. A font of the text document's font set that is a variable font also names where in its design space the type is set (each `fvar` axis in the font's order, as 16.16 fixed: the named instance's setting, or the run's own Variable Font setting) and its version string, so the type doesn't open at the - font's default instance (#6388); two settings of one font are two fonts of the set, and a static font names neither. Each run's baseline direction is written in the form the reader + font's default instance (#6388); two settings of one font are two fonts of the set. A static font names no design space, and names its version when its PostScript name alone would give the reader another installed copy (another version, or a copy of that name in another family), so the type opens in its own copy again; two such copies are two fonts of the set. A version longer than the reader takes (256 bytes) can't pick a copy: type in a copy its name alone doesn't give back is then reported as losing attributes. Each run's baseline direction is written in the form the reader below reads it: 1 when Standard Vertical Roman Alignment is on, 2 when it is off and 3 for Tate-chu-yoko; the document's defaults and the normal character style carry 1. A run with both Tate-chu-yoko and Standard Vertical Roman Alignment is written as Tate-chu-yoko, with the type attributes warning. Type in opacity masks, pattern tiles, symbols and art cut to a @@ -322,6 +322,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import (#6983): type from the text document is set in an installed face of the PostScript name its font record gives, one of exactly that name first. That face's family is no longer swapped for another family spelled alike but for case and punctuation (`FontDb::find_family`, which picked `SAMPLE INK` for `SampleInk`, the PostScript name of `Sample/Ink`); among the faces `FontDb::postscript_faces` lists for the name, the import takes one whose name matches in case and punctuation before one that matches only without them; and when the family and style alone resolve to another installed copy, the type names the face's version. A font record's key 5, the version string (name ID 5), picks that version among the faces of its PostScript name, also when the record spells the name with other punctuation (`Sample-Slab` for faces named `SampleSlab`); each record is resolved once. Type a PostScript program sets and legacy `.ai` type take the face of their font's name the same way. The `.ai` save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), so two such copies are two fonts of the set, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes): such type round-trips without the type attributes note, and so does a version that isn't installed, which draws in the face the font resolves to anyway. In 224 users' files (used locally, never committed; all PDF-compatible, so the PostScript and legacy paths are not exercised), 16 files set some type in another installed face; 12 render differently against their own page, 10 closer (in one from 2.55% to 0.01% of the page differing) and 2 further by 0.03% of the page (an envelope of type that now opens live, and a copy of the same widths in the version the record names); envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens in another way. The notes of how far a file's layers differ from its page shrink in 2 files and appear in 2 where an envelope of type now opens live, so the file is compared with its page. Two installed copies of one PostScript name, family and style that differ in widths, in files whose records keep no version, still take the copy the family and style resolve to: only the page's widths tell them apart. Tests: `tests_native::type_from_the_text_document_takes_the_face_of_its_exact_postscript_name`, `tests_native::type_from_the_text_document_takes_the_version_its_font_record_keeps`, `tests_fontnames::shown_type_takes_the_face_of_its_exact_postscript_name`, `ai::tests::legacy_type_takes_the_face_of_its_exact_postscript_name`, `ai_editing::tests::type_in_a_font_beside_a_family_spelled_alike_keeps_its_font`, `ai_editing::tests::type_in_another_version_of_a_static_font_keeps_it`, `ai_editing::tests::type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it`, `ai_editing::tests::a_version_longer_than_the_reader_takes_is_reported`, `vectorcraft-text` `tests::versions::every_face_of_a_postscript_name_is_found`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. |