diff --git a/Cargo.lock b/Cargo.lock index 2e6e5de63..8151d195b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -981,6 +981,7 @@ dependencies = [ "kurbo", "log", "skrifa 0.47.0", + "tempfile", "unicode-bidi", ] diff --git a/ROADMAP.md b/ROADMAP.md index d54c160f0..f58ea9cb5 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-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; 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-24 · **Change:** minor (localized font aliases, canonical-family precedence, resolved-face metric ownership and runtime-only regressions; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; 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-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; 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-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; 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) @@ -161,6 +161,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 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-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. | | 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) | @@ -272,6 +273,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-22 | trivial | Progress log: table text takes the master's other-text style | | 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas | | 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes | 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 751f36ca9..bd13d8614 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, @@ -844,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(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) + .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| (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.family.eq_ignore_ascii_case(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))); @@ -968,8 +1034,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 +1060,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 +1129,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 +1181,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..b1bfa86db 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,132 @@ 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); + } + 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}"); + } + } + } + + #[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)); + 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] + 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..56f69069b --- /dev/null +++ b/crates/fonts/tests/support/font_fixture.rs @@ -0,0 +1,117 @@ +//! 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 { + 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) + .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); + // 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 [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()); + 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 e70c33046..0ef341fa2 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1903,6 +1903,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); @@ -1935,8 +1950,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.family.eq_ignore_ascii_case(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 0df5db4e0..bf06fa948 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,92 @@ 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 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 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] } { + 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 d5f67246a..bce5daf8b 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-24 · **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** @@ -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 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 |