From b9caaf5f76ae3ecd59f0c418fb14aec0d7bcacbd Mon Sep 17 00:00:00 2001 From: yuxiaoli <5307302+yuxiaoli@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:37:08 +0800 Subject: [PATCH 1/4] FONT-LOCALIZED: resolve aliases after canonical families --- Cargo.lock | 1 + ROADMAP.md | 4 +- crates/fonts/Cargo.toml | 3 + crates/fonts/src/fontdb.rs | 95 ++++++++++++---- crates/fonts/src/lib.rs | 119 +++++++++++++++++++++ crates/fonts/tests/support/font_fixture.rs | 104 ++++++++++++++++++ crates/text/src/lib.rs | 2 +- crates/text/src/tests.rs | 32 ++++++ docs/file-format-parity.md | 5 +- 9 files changed, 341 insertions(+), 24 deletions(-) create mode 100644 crates/fonts/tests/support/font_fixture.rs diff --git a/Cargo.lock b/Cargo.lock index 66c79f0b9..3e677c43f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,6 +985,7 @@ dependencies = [ "kurbo", "log", "skrifa 0.47.0", + "tempfile", "unicode-bidi", ] diff --git a/ROADMAP.md b/ROADMAP.md index 93bc931ee..a86ad2980 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (localized font aliases, canonical-family precedence and runtime-only regressions; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -140,6 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | +| 2026-10-20 | Font lookup recognizes localized OpenType family names in loaded fonts and the system catalog, preserving canonical font menus and legacy weight selection. Text layout recognizes those aliases when selecting the requested face's line metrics. Exact canonical families precede aliases, including style ranking and cached variable instances; runtime-only synthetic regressions. CJK parity remains partial | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | @@ -244,6 +245,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 2026-10-20 | minor | Localized font family lookup; synthetic multilingual font regressions. Parity scores unchanged | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 64944f0f0..dcdbd425c 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -16,3 +16,6 @@ unicode-bidi = { workspace = true } log = { workspace = true } # Last-resort face only (Ubuntu Light); no UI code. epaint_default_fonts = "0.36" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 032673841..7e8082490 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -116,6 +116,8 @@ pub struct FontFace { /// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name /// fonts this way. pub legacy: Option<(String, String)>, + family_aliases: Vec, + legacy_aliases: Vec, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -208,6 +210,21 @@ impl std::fmt::Debug for FontFace { } impl FontFace { + /// Whether a document family name identifies this face, including localized and Windows names. + pub fn has_family_name(&self, family: &str) -> bool { + self.matches_family(family) || self.matches_legacy(family) + } + + fn matches_family(&self, family: &str) -> bool { + self.family.eq_ignore_ascii_case(family) || self.family_aliases.iter().any(|a| a.eq_ignore_ascii_case(family)) + } + + fn matches_legacy(&self, family: &str) -> bool { + self.legacy + .as_ref() + .is_some_and(|(name, _)| name.eq_ignore_ascii_case(family) || self.legacy_aliases.iter().any(|a| a.eq_ignore_ascii_case(family))) + } + pub fn data(&self) -> &[u8] { match &self.bytes { FontBytes::Static(b) => b, @@ -294,9 +311,17 @@ impl FontFace { struct CatalogEntry { family: String, style: String, + aliases: Vec, path: std::path::PathBuf, } +#[cfg(not(target_arch = "wasm32"))] +impl CatalogEntry { + fn matches_family(&self, family: &str) -> bool { + self.family.eq_ignore_ascii_case(family) || self.aliases.iter().any(|a| a.eq_ignore_ascii_case(family)) + } +} + /// Process-wide font database. pub struct FontDb { faces: RwLock>>, @@ -393,6 +418,30 @@ fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()))) } +/// Localized family names are lookup aliases, not additional faces or menu entries. +fn localized_names(f: &skrifa::FontRef<'_>, id: StringId) -> Vec { + let mut out: Vec = Vec::new(); + // A name table is untrusted: bound both record count and decoded string length. + for s in f.localized_strings(id).take(64) { + let s: String = s.chars().take(1025).collect(); + if !s.is_empty() && s.chars().count() <= 1024 && !out.iter().any(|n| n.eq_ignore_ascii_case(&s)) { + out.push(s); + } + } + out +} + +fn family_aliases(f: &skrifa::FontRef<'_>, family: &str) -> (Vec, Vec) { + let mut aliases = localized_names(f, StringId::TYPOGRAPHIC_FAMILY_NAME); + let windows = localized_names(f, StringId::FAMILY_NAME); + if legacy_names(f, family).is_some() { + (aliases, windows) + } else { + aliases.extend(windows); + (aliases, Vec::new()) + } +} + /// The tables skrifa reads glyph outlines from: `glyf` (TrueType), `CFF `, `CFF2` and `VARC`. For a /// face with none of them (bitmaps only, or outlines in Apple's `hvgl` table), [`FontDb::outline`] /// returns an empty path for every glyph. @@ -404,7 +453,7 @@ const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; /// variable font is cataloged under its default style; its named instances appear once it loads. /// Faces with none of the [`OUTLINE_TABLES`] are left out. #[cfg(not(target_arch = "wasm32"))] -fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { +fn file_face_names(path: &std::path::Path) -> Vec<(String, String, Vec)> { use std::io::{Read, Seek, SeekFrom}; /// Caps on what a (possibly damaged) file can make the scan read. const MAX_FACES: u32 = 256; @@ -451,9 +500,10 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { font.extend_from_slice(&table); let f = skrifa::FontRef::new(&font).ok()?; let (family, style) = face_names(&f)?; + let (aliases, legacy_aliases) = family_aliases(&f, &family); // Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file. - let legacy = legacy_names(&f, &family); - Some(std::iter::once((family, style)).chain(legacy)) + let legacy = legacy_names(&f, &family).map(|(n, s)| (n, s, legacy_aliases)); + Some(std::iter::once((family, style, aliases)).chain(legacy)) }) .flatten() .collect() @@ -687,11 +737,14 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords .or(axis(b"slnt").map(|v| v.abs() > 0.1)) .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None }; + let (family_aliases, legacy_aliases) = family_aliases(&f, &family); Some(FontFace { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, style, legacy, + family_aliases, + legacy_aliases, weight, italic, upem: m.units_per_em.max(1) as f64, @@ -847,12 +900,12 @@ impl FontDb { let mut v: Vec<(bool, f32, String)> = self .read_faces() .iter() - .filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) + .filter(|f| f.matches_family(family) && !f.style.contains('{')) .map(|f| (f.italic, f.weight, f.style.clone())) .collect(); #[cfg(not(target_arch = "wasm32"))] for c in self.read_catalog().iter() { - if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { + if c.matches_family(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); } } @@ -968,8 +1021,8 @@ impl FontDb { if !font_file_ok(&meta) { continue; } - for (family, style) in file_face_names(&p) { - found.push(CatalogEntry { family, style, path: p.clone() }); + for (family, style, aliases) in file_face_names(&p) { + found.push(CatalogEntry { family, style, aliases, path: p.clone() }); } } } @@ -994,7 +1047,7 @@ impl FontDb { // loads only that style's file, and the family's other files must still load later. let paths: Vec = { let cat = self.read_catalog(); - let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); + let mut p: Vec<_> = cat.iter().filter(|c| c.matches_family(family)).map(|c| c.path.clone()).collect(); p.sort(); p.dedup(); p @@ -1063,16 +1116,14 @@ impl FontDb { return true; } #[cfg(not(target_arch = "wasm32"))] - if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { + if self.read_catalog().iter().any(|c| c.matches_family(family)) { return true; } false } pub(crate) fn is_loaded(&self, family: &str) -> bool { - self.read_faces() - .iter() - .any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))) + self.read_faces().iter().any(|f| f.matches_family(family) || f.matches_legacy(family)) } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. @@ -1117,22 +1168,26 @@ impl FontDb { } fn find(&self, family: &str, style: &str) -> Option> { + let faces = self.read_faces(); + // Choose the family before ranking styles or reusing variable instances: another + // family's localized name must never shadow an exact canonical family name. + let mut cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + if cands.is_empty() { + cands = faces.iter().filter(|f| f.matches_family(family)).collect(); + } if style.contains('{') { - { - let faces = self.read_faces(); - if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) { - return Some(f.clone()); - } + if let Some(f) = cands.iter().find(|f| f.style == style) { + return Some((*f).clone()); } + drop(cands); + drop(faces); return self.instance(family, style); } - let faces = self.read_faces(); - let mut cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); let ns = norm(style); if cands.is_empty() { // A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by // their Windows style ("Regular", "Bold"…). - cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect(); + cands = faces.iter().filter(|f| f.matches_legacy(family)).collect(); if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) { return Some((*f).clone()); } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a9f0c0501..ffb4aa825 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -13,6 +13,10 @@ mod fontdb; pub mod script; mod shape; +#[cfg(test)] +#[path = "../tests/support/font_fixture.rs"] +mod test_font; + pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets}; pub use fontdb::{ FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs, @@ -319,6 +323,121 @@ mod tests { } } + #[test] + fn localized_family_names_resolve_loaded_and_cataloged_faces() { + let check = |db: &FontDb| { + for alias in ["DeckCraft 测试字体", "DeckCraft Police locale"] { + assert!(db.has_family(alias), "{alias}"); + assert_eq!(db.styles(alias), ["Regular", "Semibold", "Bold"]); + for style in ["Regular", "Bold", "Semibold"] { + let face = db.document_face(alias, style); + assert_eq!((face.family.as_str(), face.style.as_str()), ("DeckCraft Localized", style), "{alias}"); + assert!(face.has_family_name(alias)); + assert!(std::sync::Arc::ptr_eq(&face, &db.face("DeckCraft Localized", style))); + } + } + for alias in ["DeckCraft 测试字体 Semibold", "DeckCraft Police locale Semibold"] { + assert!(db.has_family(alias), "{alias}"); + for style in ["Regular", "Bold"] { + assert_eq!(db.document_face(alias, style).style, "Semibold", "{alias}"); + } + } + assert!(!db.families().iter().any(|f| f == "DeckCraft 测试字体"), "aliases do not duplicate the font menu"); + }; + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let fonts = [("Regular", 400), ("Bold", 700), ("Semibold", 600)]; + for (style, weight) in fonts { + assert_eq!(db.add_font(test_font::localized(style, weight)), 1); + } + let face = db.face("DeckCraft 测试字体", "Regular"); + assert!(face.covers('字')); + assert!(!db.outline(&face, face.glyph_for('字')).elements().is_empty()); + assert!((face.line_split - 0.9).abs() < 1e-6); + check(&db); + #[cfg(not(target_arch = "wasm32"))] + { + let dir = tempfile::tempdir().unwrap(); + for (style, weight) in fonts { + std::fs::write(dir.path().join(format!("{style}.ttf")), test_font::localized(style, weight)).unwrap(); + } + let db = FontDb::with_font_dirs(vec![dir.path().into()]); + db.set_system_fallback(false); + check(&db); + } + } + + #[test] + fn canonical_family_wins_same_style_alias_collisions_in_both_load_orders() { + for alias_first in [false, true] { + let db = FontDb::with_font_dirs(Vec::new()); + let canonical = test_font::font("DeckCraft Exact", "Regular", 400, &["DeckCraft Exact Local"], (900, 100), false); + let alias = test_font::font("DeckCraft Other", "Regular", 400, &["DeckCraft Exact"], (800, 200), false); + for font in if alias_first { [alias, canonical] } else { [canonical, alias] } { + assert_eq!(db.add_font(font), 1); + } + for name in ["DeckCraft Exact", "deckcraft exact", "DeckCraft Exact Local"] { + assert_eq!(db.face(name, "Regular").family, "DeckCraft Exact", "{alias_first}, {name}"); + } + assert_eq!(db.face("DeckCraft Other", "Regular").family, "DeckCraft Other"); + } + } + + #[test] + fn canonical_family_is_chosen_before_exact_or_nearest_style() { + for alias_first in [false, true] { + let db = FontDb::with_font_dirs(Vec::new()); + let regular = test_font::font("DeckCraft Exact", "Regular", 400, &[], (900, 100), false); + let black = test_font::font("DeckCraft Exact", "Black", 900, &[], (900, 100), false); + let alias = test_font::font("DeckCraft Other", "Bold", 700, &["DeckCraft Exact"], (800, 200), false); + for font in if alias_first { [alias, regular, black] } else { [regular, black, alias] } { + assert_eq!(db.add_font(font), 1); + } + for (requested, expected) in [("Bold", "Black"), ("Medium", "Regular"), ("Semibold", "Regular")] { + let face = db.face("DeckCraft Exact", requested); + assert_eq!((face.family.as_str(), face.style.as_str()), ("DeckCraft Exact", expected), "{alias_first}, {requested}"); + } + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn cataloged_canonical_family_wins_an_already_loaded_alias() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("canonical.ttf"), test_font::font("DeckCraft Exact", "Regular", 400, &[], (900, 100), false)).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.path().into()]); + db.add_font(test_font::font("DeckCraft Other", "Bold", 700, &["DeckCraft Exact"], (800, 200), false)); + let face = db.face("DeckCraft Exact", "Bold"); + assert_eq!((face.family.as_str(), face.style.as_str()), ("DeckCraft Exact", "Regular")); + } + + #[test] + fn cached_alias_variable_instances_do_not_shadow_a_canonical_family() { + for canonical_variable in [false, true] { + for alias_first in [false, true] { + let db = FontDb::with_font_dirs(Vec::new()); + let canonical = test_font::font("DeckCraft Exact", "Regular", 400, &["DeckCraft Exact Local"], (900, 100), canonical_variable); + let alias = test_font::font("DeckCraft Other", "Regular", 400, &["DeckCraft Exact"], (800, 200), true); + if !alias_first { + assert_eq!(db.add_font(canonical.clone()), 1); + } + assert_eq!(db.add_font(alias), 1); + let style = "Regular {wght:650}"; + let other = db.face("DeckCraft Other", style); + assert_eq!(other.coords, [(*b"wght", 650.0)]); + if alias_first { + assert_eq!(db.add_font(canonical), 1); + } + let face = db.face("DeckCraft Exact", style); + assert_eq!(face.family, "DeckCraft Exact", "{canonical_variable}, {alias_first}"); + assert_eq!(face.is_variable(), canonical_variable); + assert!(std::sync::Arc::ptr_eq(&face, &db.face("DeckCraft Exact", style))); + assert!(std::sync::Arc::ptr_eq(&face, &db.face("DeckCraft Exact Local", style))); + assert!(std::sync::Arc::ptr_eq(&other, &db.face("DeckCraft Other", style))); + } + } + } + #[test] fn faces_without_outlines_are_not_cataloged() { // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in diff --git a/crates/fonts/tests/support/font_fixture.rs b/crates/fonts/tests/support/font_fixture.rs new file mode 100644 index 000000000..71cf3f5a5 --- /dev/null +++ b/crates/fonts/tests/support/font_fixture.rs @@ -0,0 +1,104 @@ +//! Runtime-only synthetic fonts, derived from the existing contributor-original minimal sfnt. +//! No new font asset is stored in the repository. Glyph A's square also maps to Chinese 字. + +use std::collections::BTreeMap; + +pub fn localized(style: &str, weight: u16) -> Vec { + font("DeckCraft Localized", style, weight, &["DeckCraft 测试字体", "DeckCraft Police locale"], (900, 100), false) +} + +pub fn font(family: &str, style: &str, weight: u16, aliases: &[&str], win_metrics: (u16, u16), variable: bool) -> Vec { + let base = include_bytes!("../fixtures/legacy-regular.ttf"); + let count = u16::from_be_bytes(base[4..6].try_into().unwrap()) as usize; + let mut tables: BTreeMap<[u8; 4], Vec> = (0..count) + .map(|i| { + let r = &base[12 + i * 16..28 + i * 16]; + let start = u32::from_be_bytes(r[8..12].try_into().unwrap()) as usize; + let len = u32::from_be_bytes(r[12..16].try_into().unwrap()) as usize; + (r[..4].try_into().unwrap(), base[start..start + len].to_vec()) + }) + .collect(); + let legacy = if style == "Semibold" { format!("{family} Semibold") } else { family.to_owned() }; + let legacy_style = if style == "Semibold" { "Regular" } else { style }; + let mut names = vec![(1, 0x0409, legacy), (2, 0x0409, legacy_style.into()), (16, 0x0409, family.into()), (17, 0x0409, style.into())]; + for (i, alias) in aliases.iter().enumerate() { + let lang = if i == 0 { 0x0804 } else { 0x040c }; + let legacy = if style == "Semibold" { format!("{alias} Semibold") } else { (*alias).to_owned() }; + names.extend([(1, lang, legacy), (16, lang, (*alias).to_owned())]); + } + if variable { + names.push((256, 0x0409, "Weight".into())); + // One weight axis, no named instances: exercises FontDb's lazily cached user coordinates. + let mut fvar = vec![]; + for v in [1_u16, 0, 16, 2, 1, 20, 0, 8] { + fvar.extend(v.to_be_bytes()); + } + fvar.extend(b"wght"); + for v in [100_i32, 400, 900] { + fvar.extend((v << 16).to_be_bytes()); + } + fvar.extend([0, 0, 1, 0]); // flags, axis name ID 256 + tables.insert(*b"fvar", fvar); + } + names.sort_by_key(|(id, lang, _)| (*lang, *id)); + let mut records = vec![]; + let mut strings = vec![]; + for (id, lang, text) in names { + let bytes: Vec<_> = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for v in [3_u16, 1, lang, id, bytes.len() as u16, strings.len() as u16] { + records.extend(v.to_be_bytes()); + } + strings.extend(bytes); + } + let mut name = vec![]; + for v in [0_u16, (records.len() / 12) as u16, (6 + records.len()) as u16] { + name.extend(v.to_be_bytes()); + } + name.extend(records); + name.extend(strings); + tables.insert(*b"name", name); + // A Windows UCS-4 cmap: A and 字 both draw the original square, rather than falling back. + let mut cmap = vec![0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12, 0, 12, 0, 0]; + for v in [40_u32, 0, 2, 65, 65, 1, 0x5b57, 0x5b57, 1] { + cmap.extend(v.to_be_bytes()); + } + tables.insert(*b"cmap", cmap); + let os2 = tables.get_mut(b"OS/2").unwrap(); + os2[4..6].copy_from_slice(&weight.to_be_bytes()); + os2[74..76].copy_from_slice(&win_metrics.0.to_be_bytes()); + os2[76..78].copy_from_slice(&win_metrics.1.to_be_bytes()); + // Rebuild the directory so replacements and the optional fvar have correct offsets/checksums. + tables.get_mut(b"head").unwrap()[8..12].fill(0); + let n = tables.len() as u16; + let range = (1_u16 << n.ilog2()) * 16; + let mut out = 0x0001_0000_u32.to_be_bytes().to_vec(); + for v in [n, range, n.ilog2() as u16, n * 16 - range] { + out.extend(v.to_be_bytes()); + } + out.resize(12 + tables.len() * 16, 0); + let mut head = 0; + for (i, (tag, data)) in tables.into_iter().enumerate() { + let offset = out.len(); + let record = &mut out[12 + i * 16..28 + i * 16]; + record[..4].copy_from_slice(&tag); + record[4..8].copy_from_slice(&checksum(&data).to_be_bytes()); + record[8..12].copy_from_slice(&(offset as u32).to_be_bytes()); + record[12..16].copy_from_slice(&(data.len() as u32).to_be_bytes()); + if tag == *b"head" { + head = offset; + } + out.extend(data); + out.resize(out.len().next_multiple_of(4), 0); + } + let adjustment = 0xb1b0_afba_u32.wrapping_sub(checksum(&out)); + out[head + 8..head + 12].copy_from_slice(&adjustment.to_be_bytes()); + out +} + +fn checksum(data: &[u8]) -> u32 { + data.chunks(4).fold(0_u32, |sum, chunk| { + let mut word = [0; 4]; + word[..chunk.len()].copy_from_slice(chunk); + sum.wrapping_add(u32::from_be_bytes(word)) + }) +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9acc26c04..a6704d92d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1704,7 +1704,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts // The run's own fonts set the line; a fallback face (emoji, a symbol or a // script the run's fonts lack) does not move the baseline in PowerPoint. let own = Arc::ptr_eq(face, &st.face) - || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); + || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.has_family_name(f)); let (a, d) = line_metrics(if own { face } else { &st.face }, sz); asc = asc.max(a); desc = desc.max(d); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a9d936cd4..37fa66746 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -4,6 +4,9 @@ use deckcraft_model::defaults; use deckcraft_model::text::{Paragraph, Run}; use deckcraft_model::{LayoutType, Presentation, Xfrm}; +#[path = "../../fonts/tests/support/font_fixture.rs"] +mod test_font; + fn setup(text: &str, w: f64, h: f64) -> (Presentation, Shape) { let mut p = Presentation::default(); let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); @@ -36,6 +39,35 @@ fn wraps_long_text_into_lines() { assert!(!l.runs.is_empty()); } +#[test] +fn localized_east_asian_family_keeps_the_requested_faces_line_metrics() { + let db = FontDb::global(); + db.add_font(test_font::localized("Regular", 400)); + db.add_font(test_font::font("DeckCraft Metrics Latin", "Regular", 400, &[], (600, 400), false)); + assert!(db.face("DeckCraft Localized", "Regular").covers('字')); + assert!((db.face("DeckCraft Localized", "Regular").line_split - db.face("DeckCraft Metrics Latin", "Regular").line_split).abs() > 0.2); + let draw = |family: &str| { + let (p, mut sh) = setup("字字", 400.0, 160.0); + sh.ph = None; + let body = sh.text.as_mut().unwrap(); + body.body.anchor = Some(Anchor::Middle); + body.paragraphs[0].props.bullet = Some(Bullet::None); + let props = &mut body.paragraphs[0].runs[0].props; + props.font = Some("DeckCraft Metrics Latin".into()); + props.font_ea = Some(family.into()); + props.size = Some(60.0); + lay(&p, &sh, 400.0, 160.0) + }; + let canonical = draw("DeckCraft Localized"); + let localized = draw("DeckCraft 测试字体"); + assert_eq!(canonical.runs[0].face.family, "DeckCraft Localized"); + assert_eq!(localized.runs[0].face.family, "DeckCraft Localized"); + let latin = draw("DeckCraft Metrics Latin"); + assert!((canonical.lines[0].baseline - latin.lines[0].baseline).abs() > 1.0, "the fixture has distinct line metrics"); + assert!((canonical.lines[0].baseline - localized.lines[0].baseline).abs() < 1e-6); + assert!((canonical.content_height - localized.content_height).abs() < 1e-6); +} + #[test] fn bullets_are_drawn_for_body_text() { let (p, sh) = setup("One\nTwo", 600.0, 300.0); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 87a363222..558e10f22 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (localized font aliases, canonical-family precedence and runtime-only regressions; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -82,7 +82,7 @@ the round trip even though DeckCraft doesn't model it. | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | -| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | +| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Localized family names select loaded and installed fonts through their OpenType name-table aliases. Round-trip tests incl. `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | | Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow | | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | @@ -125,6 +125,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | | 2026-10-16 | minor | PNG slide export with a transparent background (#784) | | 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) | +| 2026-10-20 | minor | Localized font family names resolve to installed or loaded faces, including localized legacy weight families, and retain their line metrics; runtime-generated Chinese/French fonts and text-layout regressions; exact canonical families precede aliases, including style ranking and cached variable instances. CJK text remains partial | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | From fd7d456da3c6be41ae2f7df4daec69b43ebab77f Mon Sep 17 00:00:00 2001 From: yuxiaoli <5307302+yuxiaoli@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:50:18 +0800 Subject: [PATCH 2/4] FONT-LOCALIZED: keep canonical style lists free of alias faces --- ROADMAP.md | 2 +- crates/fonts/src/fontdb.rs | 25 +++++++++++++++++++------ crates/fonts/src/lib.rs | 11 +++++++++++ docs/file-format-parity.md | 2 +- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a86ad2980..68bba91fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,7 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | -| 2026-10-20 | Font lookup recognizes localized OpenType family names in loaded fonts and the system catalog, preserving canonical font menus and legacy weight selection. Text layout recognizes those aliases when selecting the requested face's line metrics. Exact canonical families precede aliases, including style ranking and cached variable instances; runtime-only synthetic regressions. CJK parity remains partial | +| 2026-10-20 | Font lookup recognizes localized OpenType family names in loaded fonts and the system catalog, preserving canonical font menus and legacy weight selection. Text layout recognizes those aliases when selecting the requested face's line metrics. Exact canonical families precede aliases, including available style lists, style ranking and cached variable instances; runtime-only synthetic regressions. CJK parity remains partial | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7e8082490..101ace7ba 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -897,16 +897,29 @@ impl FontDb { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { - let mut v: Vec<(bool, f32, String)> = self - .read_faces() + // Snapshot matching catalog names before taking the faces lock. A canonical family + // excludes alias styles even when its files have not been loaded yet. + #[cfg(not(target_arch = "wasm32"))] + let catalog: Vec<_> = self + .read_catalog() + .iter() + .filter(|c| c.matches_family(family)) + .map(|c| (c.family.eq_ignore_ascii_case(family), c.style.clone())) + .collect(); + let faces = self.read_faces(); + let exact = faces.iter().any(|f| f.family.eq_ignore_ascii_case(family)); + #[cfg(not(target_arch = "wasm32"))] + let exact = exact || catalog.iter().any(|(canonical, _)| *canonical); + let mut v: Vec<(bool, f32, String)> = faces .iter() - .filter(|f| f.matches_family(family) && !f.style.contains('{')) + .filter(|f| (if exact { f.family.eq_ignore_ascii_case(family) } else { f.matches_family(family) }) && !f.style.contains('{')) .map(|f| (f.italic, f.weight, f.style.clone())) .collect(); + drop(faces); #[cfg(not(target_arch = "wasm32"))] - for c in self.read_catalog().iter() { - if c.matches_family(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { - v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + for (canonical, style) in catalog { + if (!exact || canonical) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&style)) { + v.push((style_italic(&style), style_weight(&style), style)); } } v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index ffb4aa825..b1bfa86db 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -393,6 +393,8 @@ mod tests { for font in if alias_first { [alias, regular, black] } else { [regular, black, alias] } { assert_eq!(db.add_font(font), 1); } + assert_eq!(db.styles("DeckCraft Exact"), ["Regular", "Black"], "{alias_first}"); + assert_eq!(db.styles("DeckCraft Other"), ["Bold"]); for (requested, expected) in [("Bold", "Black"), ("Medium", "Regular"), ("Semibold", "Regular")] { let face = db.face("DeckCraft Exact", requested); assert_eq!((face.family.as_str(), face.style.as_str()), ("DeckCraft Exact", expected), "{alias_first}, {requested}"); @@ -407,8 +409,17 @@ mod tests { std::fs::write(dir.path().join("canonical.ttf"), test_font::font("DeckCraft Exact", "Regular", 400, &[], (900, 100), false)).unwrap(); let db = FontDb::with_font_dirs(vec![dir.path().into()]); db.add_font(test_font::font("DeckCraft Other", "Bold", 700, &["DeckCraft Exact"], (800, 200), false)); + assert_eq!(db.styles("DeckCraft Exact"), ["Regular"], "a cataloged canonical family excludes loaded alias styles"); let face = db.face("DeckCraft Exact", "Bold"); assert_eq!((face.family.as_str(), face.style.as_str()), ("DeckCraft Exact", "Regular")); + // Conversely, a loaded canonical face excludes an alias-only catalog style. + let alias_dir = tempfile::tempdir().unwrap(); + std::fs::write(alias_dir.path().join("alias.ttf"), test_font::font("DeckCraft Other", "Bold", 700, &["DeckCraft Exact"], (800, 200), false)) + .unwrap(); + let db = FontDb::with_font_dirs(vec![alias_dir.path().into()]); + db.add_font(test_font::font("DeckCraft Exact", "Regular", 400, &[], (900, 100), false)); + assert_eq!(db.styles("DeckCraft Exact"), ["Regular"]); + assert_eq!(db.styles("DeckCraft Other"), ["Bold"]); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 558e10f22..43e6adfd8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -125,7 +125,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | | 2026-10-16 | minor | PNG slide export with a transparent background (#784) | | 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) | -| 2026-10-20 | minor | Localized font family names resolve to installed or loaded faces, including localized legacy weight families, and retain their line metrics; runtime-generated Chinese/French fonts and text-layout regressions; exact canonical families precede aliases, including style ranking and cached variable instances. CJK text remains partial | +| 2026-10-20 | minor | Localized font family names resolve to installed or loaded faces, including localized legacy weight families, and retain their line metrics; runtime-generated Chinese/French fonts and text-layout regressions; exact canonical families precede aliases, including available style lists, style ranking and cached variable instances. CJK text remains partial | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | From 1cef948021898658944654b67b9645ce4da39dbe Mon Sep 17 00:00:00 2001 From: yuxiaoli <5307302+yuxiaoli@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:31:40 +0800 Subject: [PATCH 3/4] FONT-LOCALIZED: compare resolved faces for line metric ownership --- ROADMAP.md | 4 +- crates/fonts/tests/support/font_fixture.rs | 17 ++++++- crates/text/src/lib.rs | 19 +++++++- crates/text/src/tests.rs | 56 ++++++++++++++++++++++ docs/file-format-parity.md | 4 +- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 68bba91fe..a55305a1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (localized font aliases, canonical-family precedence and runtime-only regressions; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (localized font aliases, canonical-family precedence, resolved-face metric ownership and runtime-only regressions; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -140,7 +140,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | -| 2026-10-20 | Font lookup recognizes localized OpenType family names in loaded fonts and the system catalog, preserving canonical font menus and legacy weight selection. Text layout recognizes those aliases when selecting the requested face's line metrics. Exact canonical families precede aliases, including available style lists, style ranking and cached variable instances; runtime-only synthetic regressions. CJK parity remains partial | +| 2026-10-20 | Font lookup recognizes localized OpenType family names in loaded fonts and the system catalog, preserving canonical font menus and legacy weight selection. Text layout resolves configured script faces before comparing metric ownership, so a fallback alias collision cannot change line metrics. Exact canonical families precede aliases, including available style lists, style ranking and cached variable instances; runtime-only synthetic regressions. CJK parity remains partial | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | | 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | diff --git a/crates/fonts/tests/support/font_fixture.rs b/crates/fonts/tests/support/font_fixture.rs index 71cf3f5a5..56f69069b 100644 --- a/crates/fonts/tests/support/font_fixture.rs +++ b/crates/fonts/tests/support/font_fixture.rs @@ -8,6 +8,10 @@ pub fn localized(style: &str, weight: u16) -> Vec { } pub fn font(family: &str, style: &str, weight: u16, aliases: &[&str], win_metrics: (u16, u16), variable: bool) -> Vec { + font_with_chars(family, style, weight, aliases, win_metrics, variable, &['A', '字']) +} + +pub fn font_with_chars(family: &str, style: &str, weight: u16, aliases: &[&str], win_metrics: (u16, u16), variable: bool, chars: &[char]) -> Vec { let base = include_bytes!("../fixtures/legacy-regular.ttf"); let count = u16::from_be_bytes(base[4..6].try_into().unwrap()) as usize; let mut tables: BTreeMap<[u8; 4], Vec> = (0..count) @@ -57,11 +61,20 @@ pub fn font(family: &str, style: &str, weight: u16, aliases: &[&str], win_metric name.extend(records); name.extend(strings); tables.insert(*b"name", name); - // A Windows UCS-4 cmap: A and 字 both draw the original square, rather than falling back. + // Each requested character draws the original square. UCS-4 also permits private-use + // characters for fallback tests that cannot depend on a machine's installed coverage. + let mut chars = chars.to_vec(); + chars.sort_unstable(); + chars.dedup(); let mut cmap = vec![0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12, 0, 12, 0, 0]; - for v in [40_u32, 0, 2, 65, 65, 1, 0x5b57, 0x5b57, 1] { + for v in [16 + 12 * chars.len() as u32, 0, chars.len() as u32] { cmap.extend(v.to_be_bytes()); } + for c in chars { + for v in [c as u32, c as u32, 1] { + cmap.extend(v.to_be_bytes()); + } + } tables.insert(*b"cmap", cmap); let os2 = tables.get_mut(b"OS/2").unwrap(); os2[4..6].copy_from_slice(&weight.to_be_bytes()); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index a6704d92d..809f3b93d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1671,6 +1671,21 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright_text)); } for (pi, p) in shaped_paras.iter().enumerate() { + // Resolve ownership once per style: a fallback's alias can collide with a + // configured family's canonical name without making that fallback a run font. + let db = FontDb::global(); + let configured_faces: Vec> = p + .styles + .iter() + .map(|st| { + [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()] + .into_iter() + .flatten() + .filter(|f| db.has_family(f)) + .map(|f| db.face(f, st.style_name)) + .collect() + }) + .collect(); let props = &p.props; let margin = props.margin_left.unwrap_or(0.0).max(0.0); let indent = props.indent.unwrap_or(0.0); @@ -1703,8 +1718,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let sz = st.size; // The run's own fonts set the line; a fallback face (emoji, a symbol or a // script the run's fonts lack) does not move the baseline in PowerPoint. - let own = Arc::ptr_eq(face, &st.face) - || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.has_family_name(f)); + let own = + Arc::ptr_eq(face, &st.face) || configured_faces.get(c.style).is_some_and(|fonts| fonts.iter().any(|f| Arc::ptr_eq(face, f))); let (a, d) = line_metrics(if own { face } else { &st.face }, sz); asc = asc.max(a); desc = desc.max(d); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 37fa66746..bc50b8d2a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -68,6 +68,62 @@ fn localized_east_asian_family_keeps_the_requested_faces_line_metrics() { assert!((canonical.content_height - localized.content_height).abs() < 1e-6); } +#[test] +fn fallback_alias_collision_does_not_change_configured_line_metrics() { + let db = FontDb::global(); + for (slot_index, slot) in ["ea", "cs", "sym"].into_iter().enumerate() { + for (order, fallback_first) in [false, true].into_iter().enumerate() { + let case = slot_index * 2 + order; + let configured = format!("DeckCraft Ownership Canonical {case}"); + let fallback = format!("DeckCraft Ownership Fallback {case}"); + let character = char::from_u32(0xf0840 + case as u32).unwrap(); + let primary = test_font::font_with_chars(&configured, "Regular", 400, &[], (600, 400), false, &['A']); + let other = test_font::font_with_chars(&fallback, "Regular", 400, &[&configured], (900, 100), false, &[character]); + for font in if fallback_first { [other, primary] } else { [primary, other] } { + assert_eq!(db.add_font(font), 1); + } + assert_eq!(db.face(&configured, "Regular").family, configured); + assert!(!db.face(&configured, "Regular").covers(character)); + assert!(db.face(&fallback, "Regular").covers(character)); + let draw = |text: &str, slot_family: &str| { + let (p, mut shape) = setup(text, 400.0, 200.0); + shape.ph = None; + let body = shape.text.as_mut().unwrap(); + body.body.anchor = Some(Anchor::Top); + body.body.inset_t = Some(0.0); + let para = &mut body.paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.props.line_spacing = Some(Spacing::Pct(1.0)); + let props = &mut para.runs[0].props; + props.font = Some(configured.clone()); + props.font_ea = Some(if slot == "ea" { slot_family.into() } else { String::new() }); + props.font_cs = Some(if slot == "cs" { slot_family.into() } else { String::new() }); + props.font_sym = (slot == "sym").then(|| slot_family.into()); + props.bold = Some(false); + props.italic = Some(false); + props.size = Some(60.0); + lay(&p, &shape, 400.0, 200.0) + }; + let control = draw("AA", &configured); + let mixed = format!("A{character}"); + let actual = draw(&mixed, &configured); + assert!(actual.runs.iter().any(|r| r.face.family == fallback), "the missing glyph really uses the collision face"); + assert!( + (actual.lines[0].baseline - control.lines[0].baseline).abs() < 1e-6, + "{slot}, fallback first {fallback_first}: baseline {} vs {}, height {} vs {}", + actual.lines[0].baseline, + control.lines[0].baseline, + actual.content_height, + control.content_height + ); + assert!((actual.content_height - control.content_height).abs() < 1e-6); + // A genuinely configured face still contributes its different ascent/descent. + let owned = draw(&mixed, &fallback); + assert!(owned.content_height > control.content_height + 1.0); + } + } +} + #[test] fn bullets_are_drawn_for_body_text() { let (p, sh) = setup("One\nTwo", 600.0, 300.0); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 43e6adfd8..d7824017d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (localized font aliases, canonical-family precedence and runtime-only regressions; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (localized font aliases, canonical-family precedence, resolved-face metric ownership and runtime-only regressions; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -125,7 +125,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | | 2026-10-16 | minor | PNG slide export with a transparent background (#784) | | 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) | -| 2026-10-20 | minor | Localized font family names resolve to installed or loaded faces, including localized legacy weight families, and retain their line metrics; runtime-generated Chinese/French fonts and text-layout regressions; exact canonical families precede aliases, including available style lists, style ranking and cached variable instances. CJK text remains partial | +| 2026-10-20 | minor | Localized font family names resolve to installed or loaded faces, including localized legacy weight families, and retain their line metrics; runtime-generated Chinese/French fonts and text-layout regressions; exact canonical families precede aliases, including available style lists, style ranking and cached variable instances; line metrics use resolved configured-face identity rather than alias membership. CJK text remains partial | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | From 51f0e903ab7d751ab9f83528ef8812d5cb89beac Mon Sep 17 00:00:00 2001 From: yuxiaoli <5307302+yuxiaoli@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:01:22 +0800 Subject: [PATCH 4/4] FONT-LOCALIZED: exercise the configured symbol path in metrics test --- crates/text/src/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e05fab01f..bf06fa948 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -76,7 +76,8 @@ fn fallback_alias_collision_does_not_change_configured_line_metrics() { let case = slot_index * 2 + order; let configured = format!("DeckCraft Ownership Canonical {case}"); let fallback = format!("DeckCraft Ownership Fallback {case}"); - let character = char::from_u32(0xf0840 + case as u32).unwrap(); + let code = if slot == "sym" { 0xf084 } else { 0xf0840 }; + let character = char::from_u32(code + case as u32).unwrap(); let primary = test_font::font_with_chars(&configured, "Regular", 400, &[], (600, 400), false, &['A']); let other = test_font::font_with_chars(&fallback, "Regular", 400, &[&configured], (900, 100), false, &[character]); for font in if fallback_first { [other, primary] } else { [primary, other] } {