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
@@ -5325,6 +5325,7 @@ dependencies = [ "photocraft-io", "photocraft-paint", "photocraft-psd",+ "photocraft-text", "photocraft-ui-egui", "rfd", "serde_json",@@ -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 } new file mode 100644@@ -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+}@@ -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)?;@@ -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; @@ -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.