mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Fonts: find fonts where Linux and Flatpak keep them; add and rescan commands (#277)
The font menu lists every family the scan of the system font folders finds,
but on Linux the scan only looked in /usr/share/fonts, /usr/local/share/fonts,
~/.fonts and ~/.local/share/fonts. Fonts under XDG data dirs, in folders
fontconfig is configured with (NixOS, Guix, custom setups) and, inside
Flatpak, the host's fonts never showed.
- system_font_dirs adds $XDG_DATA_HOME/fonts and each $XDG_DATA_DIRS/fonts,
Flatpak's /run/host/{fonts,local-fonts,user-fonts}, and the <dir> entries
of /etc/fonts/fonts.conf, local.conf and conf.d; duplicates are dropped.
- font.add {data} adds a font from its bytes for the session; font.rescan
catalogs the installed fonts again, and the font menu has a
"Rescan Installed Fonts" item for fonts installed while DeckCraft runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,8 @@ use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "font.add", "Add Font", [], None, "{data: base64 TTF/OTF/TTC} → {faces} added for this session", always, font_add),
|
||||
cmd!(noundo "font.rescan", "Rescan Installed Fonts", ["Home", "Font"], None, "{} → {faces, families} (fonts installed since start-up)", always, font_rescan),
|
||||
cmd!("format.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{on?: bool}", has_text_or_shapes, bold),
|
||||
cmd!("format.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{on?: bool}", has_text_or_shapes, italic),
|
||||
cmd!(
|
||||
@@ -682,3 +684,26 @@ fn state(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
"painter": s.painter.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Add a font from its bytes for this session (it then shows in the font menu and draws).
|
||||
fn font_add(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?;
|
||||
if data.len() > 64 << 20 {
|
||||
return Err(bad("font.add", "font file larger than 64 MB"));
|
||||
}
|
||||
let faces = deckcraft_fonts::FontDb::global().add_font(data);
|
||||
if faces == 0 {
|
||||
return Err(bad("font.add", "not a font DeckCraft can read (or already added)"));
|
||||
}
|
||||
Ok(json!({"faces": faces}))
|
||||
}
|
||||
|
||||
/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear.
|
||||
fn font_rescan(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let db = deckcraft_fonts::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let faces = db.load_system_fonts();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let faces = 0usize;
|
||||
Ok(json!({"faces": faces, "families": db.families().len()}))
|
||||
}
|
||||
|
||||
@@ -835,3 +835,13 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_add_refuses_non_fonts_and_rescan_reports_the_catalog() {
|
||||
let mut s = session();
|
||||
let not_font = crate::cmd::base64_encode(b"definitely not a font file");
|
||||
assert!(s.execute("font.add", &json!({"data": not_font})).is_err());
|
||||
assert!(s.execute("font.add", &json!({})).is_err());
|
||||
let r = s.execute("font.rescan", &json!({})).unwrap();
|
||||
assert!(r["families"].as_u64().unwrap() >= 1, "{r}");
|
||||
}
|
||||
|
||||
@@ -488,10 +488,87 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
dirs.push(h.join(".fonts"));
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().into_owned());
|
||||
dirs.extend(xdg_font_dirs(env("XDG_DATA_HOME").as_deref(), env("XDG_DATA_DIRS").as_deref(), home.as_deref()));
|
||||
// Inside Flatpak the host's fonts are mounted here.
|
||||
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
|
||||
// Folders fontconfig is configured with (NixOS, Guix, custom setups).
|
||||
for conf in fontconfig_files() {
|
||||
if let Ok(text) = std::fs::read_to_string(&conf) {
|
||||
dirs.extend(fontconfig_dirs(&text, env("XDG_DATA_HOME").as_deref(), home.as_deref()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
dirs.retain(|d| seen.insert(d.clone()));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// `<data dir>/fonts` for `XDG_DATA_HOME` (default `~/.local/share`) and each `XDG_DATA_DIRS`
|
||||
/// entry (default `/usr/local/share:/usr/share`).
|
||||
fn xdg_font_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
|
||||
let mut v = vec![];
|
||||
match data_home.filter(|d| !d.is_empty()) {
|
||||
Some(d) => v.push(std::path::Path::new(d).join("fonts")),
|
||||
None => {
|
||||
if let Some(h) = home {
|
||||
v.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let dirs = data_dirs.filter(|d| !d.is_empty()).unwrap_or("/usr/local/share:/usr/share");
|
||||
v.extend(dirs.split(':').filter(|d| d.starts_with('/')).take(64).map(|d| std::path::Path::new(d).join("fonts")));
|
||||
v
|
||||
}
|
||||
|
||||
/// The fontconfig configuration files: `/etc/fonts/fonts.conf` and `/etc/fonts/conf.d/*.conf`.
|
||||
fn fontconfig_files() -> Vec<std::path::PathBuf> {
|
||||
let mut v = vec![std::path::PathBuf::from("/etc/fonts/fonts.conf"), std::path::PathBuf::from("/etc/fonts/local.conf")];
|
||||
if let Ok(entries) = std::fs::read_dir("/etc/fonts/conf.d") {
|
||||
let mut files: Vec<_> = entries.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "conf")).collect();
|
||||
files.sort();
|
||||
files.truncate(512);
|
||||
v.extend(files);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The `<dir>` folders of a fontconfig file (`prefix="xdg"` is under `XDG_DATA_HOME`, a leading
|
||||
/// `~` under home; relative and `cachedir` entries are skipped).
|
||||
fn fontconfig_dirs(text: &str, data_home: Option<&str>, home: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
|
||||
let mut v = vec![];
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find("<dir") {
|
||||
let after = &rest[start + 4..];
|
||||
// `<dir>` or `<dir …>`, not `<dirname>`.
|
||||
if !(after.starts_with('>') || after.starts_with(' ')) {
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let Some(gt) = after.find('>') else { break };
|
||||
let attrs = &after[..gt];
|
||||
let body = &after[gt + 1..];
|
||||
let Some(end) = body.find("</dir>") else { break };
|
||||
let path = body[..end].trim();
|
||||
rest = &body[end..];
|
||||
if attrs.trim_end().ends_with('/') || path.is_empty() || v.len() >= 64 {
|
||||
continue;
|
||||
}
|
||||
let p = if attrs.contains("prefix=\"xdg\"") {
|
||||
let base = data_home.filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| home.map(|h| h.join(".local/share")));
|
||||
base.map(|b| b.join(path))
|
||||
} else if let Some(r) = path.strip_prefix("~/") {
|
||||
home.map(|h| h.join(r))
|
||||
} else if path.starts_with('/') {
|
||||
Some(std::path::PathBuf::from(path))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
v.extend(p);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// One face found in a font file: index, family, style and (variable fonts) the named instance's
|
||||
/// axis settings.
|
||||
type Found = (u32, String, String, Vec<([u8; 4], f32)>);
|
||||
@@ -1241,3 +1318,53 @@ impl OutlinePen for FlipPen {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod font_dir_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xdg_data_dirs_contribute_their_fonts_folders() {
|
||||
let home = Path::new("/home/ann");
|
||||
assert_eq!(
|
||||
xdg_font_dirs(None, None, Some(home)),
|
||||
[home.join(".local/share/fonts"), "/usr/local/share/fonts".into(), "/usr/share/fonts".into()]
|
||||
);
|
||||
let v = xdg_font_dirs(Some("/data/ann"), Some("/nix/profile/share:relative:/var/lib/flatpak/exports/share"), Some(home));
|
||||
assert_eq!(v, [PathBuf::from("/data/ann/fonts"), "/nix/profile/share/fonts".into(), "/var/lib/flatpak/exports/share/fonts".into()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fontconfig_dir_entries_are_read() {
|
||||
let conf = r#"<?xml version="1.0"?>
|
||||
<fontconfig>
|
||||
<dir>/usr/share/fonts</dir>
|
||||
<dir prefix="xdg">fonts</dir>
|
||||
<dir>~/.fonts</dir>
|
||||
<dir>relative/fonts</dir>
|
||||
<cachedir>/var/cache/fontconfig</cachedir>
|
||||
<dirname>ignored</dirname>
|
||||
<dir>/nix/store/abc-fonts/share/fonts</dir>
|
||||
</fontconfig>"#;
|
||||
let home = Path::new("/home/ann");
|
||||
let v = fontconfig_dirs(conf, None, Some(home));
|
||||
assert_eq!(
|
||||
v,
|
||||
[PathBuf::from("/usr/share/fonts"), home.join(".local/share/fonts"), home.join(".fonts"), "/nix/store/abc-fonts/share/fonts".into()]
|
||||
);
|
||||
assert!(fontconfig_dirs("<dir>/never closed", None, Some(home)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_dirs_include_flatpak_host_fonts_without_duplicates() {
|
||||
let d = system_font_dirs();
|
||||
assert!(d.contains(&PathBuf::from("/run/host/user-fonts")));
|
||||
let mut u = d.clone();
|
||||
u.sort();
|
||||
u.dedup();
|
||||
assert_eq!(u.len(), d.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +437,12 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
// Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts).
|
||||
if ui.button("Rescan Installed Fonts").clicked() {
|
||||
run(app, "font.rescan", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();
|
||||
dropdown(ui, "size", &size_text, 52.0, en, |ui| {
|
||||
|
||||
Reference in New Issue
Block a user