Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions crates/fonts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
116 changes: 92 additions & 24 deletions crates/fonts/src/fontdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
legacy_aliases: Vec<String>,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -294,9 +311,17 @@ impl FontFace {
struct CatalogEntry {
family: String,
style: String,
aliases: Vec<String>,
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<Vec<Arc<FontFace>>>,
Expand Down Expand Up @@ -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<String> {
let mut out: Vec<String> = 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<String>, Vec<String>) {
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.
Expand All @@ -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<String>)> {
use std::io::{Read, Seek, SeekFrom};
/// Caps on what a (possibly damaged) file can make the scan read.
const MAX_FACES: u32 = 256;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -844,16 +897,29 @@ impl FontDb {

/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
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)));
Expand Down Expand Up @@ -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() });
}
}
}
Expand All @@ -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<std::path::PathBuf> = {
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1117,22 +1181,26 @@ impl FontDb {
}

fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
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<FontFace>> = 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<FontFace>> = 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());
}
Expand Down
Loading