collective

Browse / github.com/storytold/photocraft / patch

feat: resolve and retain missing browser fonts

feat: resolve and retain missing browser fonts Imported documents can name fonts absent from the browser, leaving substituted text with no way to resolve the missing family. Offer explicit open-font download or local import, validate the family, install it through the existing served-font routine and retain it in IndexedDB. Bound font requests and collections; keep failed downloads/imports actionable. No font binaries are bundled. This shares the same bounded IndexedDB opening/deadline adaptation as the other browser-retention contribution. After either merges, the other should rebase onto that shared helper. Validation evidence: - Bui

Status
open
Score
0.00
Adopters
0 other operators
Author
muttleydosomething (imported from pull request #9725, open)
Base
59cb4c49bd236df04d8e37d4764253f29ca6600a
Size
+296 −5 in 6 files
Review
pending
Shared
2026-10-28
Id
f3839db2c8099b8c3eba1e4f

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/photocraft-web/Cargo.toml apps/photocraft-web/src/browser_fonts.rs apps/photocraft-web/src/indexed_presets.rs apps/photocraft-web/src/main.rs apps/photocraft-web/src/web.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -5325,6 +5325,7 @@ dependencies = [  "photocraft-io",  "photocraft-paint",  "photocraft-psd",+ "photocraft-text",  "photocraft-ui-egui",  "rfd",  "serde_json",diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml--- a/apps/photocraft-web/Cargo.toml+++ b/apps/photocraft-web/Cargo.toml@@ -24,6 +24,8 @@ photocraft-io = { workspace = true } photocraft-codecs = { workspace = true } photocraft-doc = { workspace = true } photocraft-ui-egui = { workspace = true }+photocraft-text = { workspace = true }+serde_json = { workspace = true } egui = { workspace = true } log = { workspace = true } diff --git a/apps/photocraft-web/src/browser_fonts.rs b/apps/photocraft-web/src/browser_fonts.rsnew file mode 100644--- /dev/null+++ b/apps/photocraft-web/src/browser_fonts.rs@@ -0,0 +1,247 @@+//! Resolve browser-only missing fonts through the existing font installer.+//! Downloads use Google's open font repository, never a guessed third-party URL.+use photocraft_ui_egui::{PhotocraftApp, served_fonts};+use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc};+use wasm_bindgen::JsCast as _;++const DATABASE: &str = "photocraft.fonts";+const ROOT: &str = "https://raw.githubusercontent.com/google/fonts/main";+const MAX_FONT: usize = 16 * 1024 * 1024;+type Files = Vec<(String, Vec<u8>)>;+type Reply = (String, Result<Option<(Files, Option<String>)>, String>);++#[derive(Default)]+pub struct Fonts {+    replies: Rc<RefCell<Vec<Reply>>>,+    pending: Vec<String>,+    messages: HashMap<String, String>,+    missing: Vec<String>,+    buttons: Vec<serde_json::Value>,+    next_scan: f64,+    hydration: Option<Files>,+}++impl Fonts {+    pub async fn load() -> Self {+        let mut fonts = Self::default();+        let loaded = async {+            let db = crate::indexed_presets::open(DATABASE, js_sys::Date::now() + 10_000.0).await?;+            let result = crate::indexed_presets::read_fonts(&db).await;+            db.close();+            result+        }+        .await;+        match loaded {+            Ok(files) => fonts.hydration = Some(files),+            Err(error) => {+                fonts.messages.insert(String::new(), format!("Retained fonts unavailable: {error}"));+            }+        }+        fonts+    }++    pub fn tick(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context) {+        if let Some(files) = self.hydration.take() {+            served_fonts::install(app, files);+        }+        for (family, result) in self.replies.take() {+            self.pending.retain(|name| name != &family);+            match result {+                Ok(Some((files, warning))) => {+                    let delivered = files.into_iter().map(|(_, bytes)| (family.clone(), bytes)).collect();+                    served_fonts::install(app, delivered);+                    let message = warning.unwrap_or_else(|| format!("{family} is ready for this and future sessions."));+                    app.ui.status = message.clone();+                    app.ui.status_error = false;+                    // Remove only font warnings which no longer apply; keep other import warnings.+                    let prefix = format!("font '{family}' is not installed;");+                    app.ui.notices.retain_mut(|notice| {+                        let had_warning = notice.lines.iter().any(|line| line.starts_with(&prefix));+                        notice.lines.retain(|line| !line.starts_with(&prefix));+                        !had_warning || !notice.lines.is_empty()+                    });+                    photocraft_ui_egui::notices::post(app, message.clone(), Vec::new(), false, None);+                    self.messages.insert(family, message);+                }+                Ok(None) => {}+                Err(error) => {+                    self.messages.insert(family, error);+                }+            }+            self.next_scan = 0.0;+        }+        let now = js_sys::Date::now();+        if now >= self.next_scan {+            self.missing = app.session.active().map(|doc| photocraft_engine::type_extra_cmds::missing_fonts(&doc.doc)).unwrap_or_default();+            self.next_scan = now + 500.0;+        }+        // Also discover a just-opened document when there are no animation frames.+        ctx.request_repaint_after(std::time::Duration::from_millis(500));+    }++    pub fn ui(&mut self, app: &PhotocraftApp, ui: &mut egui::Ui) {+        self.buttons.clear();+        if self.missing.is_empty() {+            return;+        }+        egui::Panel::top("browser_missing_fonts").show(ui, |ui| {+            let tokens = photocraft_ui_egui::theme::Tokens::for_kind(app.ui.theme);+            ui.colored_label(tokens.warning, "Missing fonts — download an open font or import your TTF/OTF.");+            ui.hyperlink_to("Google Fonts catalogue and licences", "https://fonts.google.com/");+            for family in self.missing.clone().into_iter().take(8) {+                let busy = self.pending.contains(&family);+                ui.horizontal_wrapped(|ui| {+                    ui.label(&family);+                    let download = ui.add_enabled(self.pending.is_empty(), egui::Button::new("Download from Google Fonts"));+                    let import = ui.add_enabled(self.pending.is_empty(), egui::Button::new("Import TTF/OTF…"));+                    let point = |r: &egui::Response| [r.rect.center().x, r.rect.center().y];+                    self.buttons.push(serde_json::json!({"family": family, "download": point(&download), "import": point(&import)}));+                    if download.clicked() {+                        self.begin(family.clone(), false, ui.ctx().clone());+                    }+                    if import.clicked() {+                        self.begin(family.clone(), true, ui.ctx().clone());+                    }+                    if busy {+                        ui.spinner();+                        ui.label("Loading font…");+                    }+                });+                if let Some(message) = self.messages.get(&family) {+                    ui.label(message);+                }+            }+            if let Some(message) = self.messages.get("") {+                ui.label(message);+            }+        });+    }++    fn begin(&mut self, family: String, import: bool, ctx: egui::Context) {+        if !self.pending.is_empty() {+            return;+        }+        self.pending.push(family.clone());+        self.messages.remove(&family);+        let replies = self.replies.clone();+        wasm_bindgen_futures::spawn_local(async move {+            let result = async {+                let files = if import {+                    let Some(file) = rfd::AsyncFileDialog::new().add_filter("Fonts", &["ttf", "otf", "ttc", "otc"]).pick_file().await else { return Ok(None) };+                    vec![(file.file_name(), file.read().await)]+                } else {+                    download(&family).await?+                };+                validate(&family, &files)?;+                let retained = async {+                    let db = crate::indexed_presets::open(DATABASE, js_sys::Date::now() + 10_000.0).await?;+                    let result = async {+                        let existing = crate::indexed_presets::read_fonts(&db).await?;+                        let count = existing.len().saturating_add(files.len());+                        let total = existing.iter().map(|(_, bytes)| bytes.len()).chain(files.iter().map(|(_, bytes)| bytes.len())).sum::<usize>();+                        if count > 128 || total > 64 * 1024 * 1024 {+                            return Err("The retained font collection has reached its limit".to_string());+                        }+                        let batch = files.iter().map(|(name, bytes)| (format!("{family}/{name}"), Some(Arc::<[u8]>::from(bytes.as_slice())))).collect();+                        crate::indexed_presets::write(&db, &batch).await+                    }+                    .await;+                    db.close();+                    result+                }+                .await;+                let warning = retained.err().map(|error| format!("{family} is ready in this tab only; browser retention failed: {error}"));+                Ok(Some((files, warning)))+            }+            .await;+            replies.borrow_mut().push((family, result));+            ctx.request_repaint();+        });+    }+}++fn validate(family: &str, files: &Files) -> Result<(), String> {+    if family.is_empty() || family.len() > 200 || family.contains('/') || files.iter().any(|(name, _)| name.len() > 255) {+        return Err("This font identity is too long or unsupported.".into());+    }+    let mut fonts = photocraft_text::FontDb::new();+    for (_, bytes) in files {+        if bytes.is_empty() || bytes.len() > MAX_FONT {+            return Err("Font must be between 1 byte and 16 MiB.".into());+        }+        let names = fonts.register_font_data(bytes.clone());+        if !names.iter().any(|name| name.eq_ignore_ascii_case(family)) {+            return Err(format!("This font does not contain {family}. Import the matching TTF/OTF."));+        }+    }+    Ok(())+}++async fn download(family: &str) -> Result<Files, String> {+    if family.len() > 200 || !family.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-') {+        return Err("This family is unavailable from the open catalogue. Import its TTF/OTF.".into());+    }+    let slug = family.to_ascii_lowercase().replace(' ', "");+    for license in ["ofl", "apache", "ufl"] {+        let root = format!("{ROOT}/{license}/{slug}");+        let metadata = match fetch(&format!("{root}/METADATA.pb"), 256 * 1024).await {+            Ok(Some(bytes)) => String::from_utf8(bytes).map_err(|_| "Unreadable font catalogue entry")?,+            Ok(None) => continue,+            Err(error) => return Err(format!("Download failed: {error}. Retry or import a TTF/OTF.")),+        };+        let field =+            |line: &str, key: &str| line.trim().strip_prefix(key).and_then(|s| s.trim().strip_prefix('"')).and_then(|s| s.strip_suffix('"')).map(str::to_owned);+        if !metadata.lines().find_map(|line| field(line, "name:")).is_some_and(|name| name.eq_ignore_ascii_case(family)) {+            continue;+        }+        let names: Vec<String> = metadata.lines().filter_map(|line| field(line, "filename:")).collect();+        if names.is_empty() || names.len() > 32 {+            return Err("No supported font files in this catalogue entry. Import TTF/OTF.".into());+        }+        let mut files = Vec::new();+        let mut total = 0usize;+        for name in names {+            if name.contains('/') || name.contains('\\') || name.contains("..") || !(name.ends_with(".ttf") || name.ends_with(".otf")) {+                return Err("Unsupported catalogue font file. Import TTF/OTF.".into());+            }+            let path = js_sys::encode_uri_component(&name).as_string().ok_or("Invalid font filename")?;+            let bytes = fetch(&format!("{root}/{path}"), MAX_FONT).await?.ok_or("Font download is unavailable. Retry or import TTF/OTF.")?;+            total = total.saturating_add(bytes.len());+            if total > 64 * 1024 * 1024 {+                return Err("Font family exceeds the 64 MiB limit.".into());+            }+            files.push((name, bytes));+        }+        return Ok(files);+    }+    Err("Not available from Google Fonts. Import the matching licensed TTF/OTF instead.".into())+}++async fn fetch(url: &str, max: usize) -> Result<Option<Vec<u8>>, String> {+    let window = web_sys::window().ok_or("Browser unavailable")?;+    let abort = web_sys::AbortController::new().map_err(|_| "Cannot start font request")?;+    let init = web_sys::RequestInit::new();+    init.set_signal(Some(&abort.signal()));+    let deadline = js_sys::Date::now() + 10_000.0;+    let result = async {+        let response = crate::indexed_presets::before(&window.fetch_with_str_and_init(url, &init), deadline)+            .await?+            .dyn_into::<web_sys::Response>()+            .map_err(|_| "Invalid font response")?;+        if response.status() == 404 {+            return Ok(None);+        }+        if !response.ok() {+            return Err(format!("Font service returned HTTP {}", response.status()));+        }+        let body = crate::indexed_presets::before(&response.array_buffer().map_err(|_| "Cannot read font response")?, deadline).await?;+        let data = js_sys::Uint8Array::new(&body);+        if data.length() as usize > max {+            return Err("Font response exceeds its limit".into());+        }+        Ok(Some(data.to_vec()))+    }+    .await;+    abort.abort();+    result+}diff --git a/apps/photocraft-web/src/indexed_presets.rs b/apps/photocraft-web/src/indexed_presets.rs--- a/apps/photocraft-web/src/indexed_presets.rs+++ b/apps/photocraft-web/src/indexed_presets.rs@@ -75,7 +75,7 @@ fn request(request: &IdbRequest) -> Pending { }  /// A single deadline bounds the whole startup read, not ten seconds per record.-async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {+pub(crate) async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {     let window = web_sys::window().ok_or("no window")?;     let mut timer = None;     let mut callback = None;@@ -96,9 +96,10 @@ async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {     result } -async fn open(deadline: f64) -> Result<IdbDatabase, String> {+/// Open an isolated browser collection using the same bounded transaction machinery.+pub(crate) async fn open(name: &str, deadline: f64) -> Result<IdbDatabase, String> {     let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB unavailable")?;-    let req = factory.open_with_u32(DATABASE, 1).map_err(js)?;+    let req = factory.open_with_u32(name, 1).map_err(js)?;     let mut upgrade = Listeners::new();     let upgrade_req = req.clone();     upgrade@@ -133,7 +134,7 @@ async fn open(deadline: f64) -> Result<IdbDatabase, String> {  pub async fn load() -> Result<(IdbDatabase, Bridge, Vec<String>), String> {     let deadline = js_sys::Date::now() + TIMEOUT_MS;-    let db = open(deadline).await?;+    let db = open(DATABASE, deadline).await?;     let result = read(&db, deadline).await;     match result {         Ok((bridge, warnings)) => Ok((db, bridge, warnings)),@@ -209,6 +210,39 @@ async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec<String>),     Ok((bridge, warnings)) } +/// Font collection: enumerate Blobs before awaiting their bodies, with a single deadline.+pub(crate) async fn read_fonts(db: &IdbDatabase) -> Result<Vec<(String, Vec<u8>)>, String> {+    let deadline = js_sys::Date::now() + TIMEOUT_MS;+    let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readonly).map_err(js)?;+    let req = tx.object_store(FILES).map_err(js)?.open_cursor().map_err(js)?;+    let mut next = request(&req);+    let mut records = Vec::new();+    let mut total = 0.0;+    loop {+        let value = before(&next.promise, deadline).await?;+        if value.is_null() {+            break;+        }+        let cursor = value.dyn_into::<web_sys::IdbCursorWithValue>().map_err(|_| "invalid font entry")?;+        let name = cursor.key().map_err(js)?.as_string().ok_or("invalid font name")?;+        let blob = cursor.value().map_err(js)?.dyn_into::<Blob>().map_err(|_| "invalid font data")?;+        total += blob.size();+        if records.len() >= 128 || name.len() > 512 || blob.size() > 16_777_216.0 || total > 67_108_864.0 {+            return Err("Font collection exceeds its browser limits".into());+        }+        records.push((name, blob));+        next = request(&req);+        cursor.continue_().map_err(js)?;+    }+    let mut fonts = Vec::new();+    for (key, blob) in records {+        let family = key.split_once('/').map(|(family, _)| family).ok_or("invalid font identity")?;+        let body = before(&blob.array_buffer(), deadline).await?;+        fonts.push((family.to_owned(), Uint8Array::new(&body).to_vec()));+    }+    Ok(fonts)+}+ pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> {     let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)?;     let store = tx.object_store(FILES).map_err(js)?;diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs--- a/apps/photocraft-web/src/main.rs+++ b/apps/photocraft-web/src/main.rs@@ -22,6 +22,9 @@ mod preset_bridge; #[cfg(target_arch = "wasm32")] mod indexed_presets; +#[cfg(target_arch = "wasm32")]+mod browser_fonts;+ #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs--- a/apps/photocraft-web/src/web.rs+++ b/apps/photocraft-web/src/web.rs@@ -75,6 +75,7 @@ pub fn start() {             }         };         let pen_target = canvas.clone();+        let fonts = crate::browser_fonts::Fonts::load().await;         let result = eframe::WebRunner::new()             .start(                 canvas,@@ -103,7 +104,7 @@ pub fn start() {                     }                     let unsaved = Arc::new(AtomicBool::new(false));                     guard_unload(unsaved.clone());-                    Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))+                    Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, fonts }))                 }),             )             .await;@@ -270,6 +271,7 @@ struct WebShell {     served: Served,     database: Option<web_sys::IdbDatabase>,     presets: crate::preset_bridge::Bridge,+    fonts: crate::browser_fonts::Fonts, }  impl WebShell {@@ -336,6 +338,7 @@ impl eframe::App for WebShell {     }      fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {+        self.fonts.tick(&mut self.app, ctx);         let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));         if !dropped.is_empty() {             let names: Vec<String> =@@ -367,6 +370,7 @@ impl eframe::App for WebShell {     }      fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {+        self.fonts.ui(&self.app, ui);         // Only while a preset change is unsaved. Storage the browser denies from the start (a         // sandboxed iframe, blocked site data) loses nothing until the first change, and that         // change brings the banner with the reason.