diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 24a3d19512..6b4c62797a 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 8c031c4de1..83fa57e66a 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -30,8 +30,8 @@ const FONTS_MANIFEST: &str = "fonts/manifest.txt"; /// The served fonts: the manifest once read, and what the network has delivered. #[derive(Default)] struct ServedFonts { - /// Font files by family. - files: HashMap>, + /// Font files by family, each with its Subresource Integrity value when the manifest has one. + files: HashMap)>>, /// Families fetched or being fetched: each one once per page load. fetched: HashSet, /// Downloaded files waiting for the next frame: (family, bytes). @@ -165,7 +165,7 @@ fn query() -> String { fn load_font_manifest(served: Served, ctx: egui::Context) { wasm_bindgen_futures::spawn_local(async move { // Most hosts serve no fonts: a missing manifest is the normal case, not an error. - let Ok(bytes) = fetch_bytes(FONTS_MANIFEST).await else { return }; + let Ok(bytes) = fetch_bytes(FONTS_MANIFEST, None).await else { return }; let text = String::from_utf8_lossy(&bytes); // A host that answers unknown paths with its HTML page (single-page app fallback). if text.trim_start().starts_with('<') { @@ -175,24 +175,41 @@ fn load_font_manifest(served: Served, ctx: egui::Context) { for s in skipped { log::warn!("{FONTS_MANIFEST}: skipped {s}"); } - let mut files: HashMap> = HashMap::new(); - for f in fonts { - files.entry(f.family).or_default().push(f.file); + let mut files: HashMap)>> = HashMap::new(); + for f in &fonts { + files.entry(f.family.clone()).or_default().push((f.file.clone(), f.integrity.clone())); } log::info!("photocraft-web: {} served font families", files.len()); - let families: Vec = files.keys().cloned().collect(); served.lock().unwrap_or_else(|e| e.into_inner()).files = files; - served_fonts::add_families(families); + // The families for the font menus, and the script fallbacks (the manifest's `scripts`). + served_fonts::add_fonts(&fonts); + // Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets + // the Arabic font before typing); the rest is fetched when text or a font menu needs it. + served_fonts::request_for_languages(browser_languages().iter().map(String::as_str)); ctx.request_repaint(); }); } -/// GET `url` (relative to the page) and read the whole body. -async fn fetch_bytes(url: &str) -> Result, String> { +/// The browser's preferred languages (`navigator.languages`, e.g. `["ar-EG", "en"]`). +fn browser_languages() -> Vec { + web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default() +} + +/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource +/// Integrity value), the browser rejects a body whose hash differs. +async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result, String> { use wasm_bindgen_futures::JsFuture; let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let window = web_sys::window().ok_or("no window")?; - let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(url)).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?; + let request = match integrity { + Some(sri) => { + let init = web_sys::RequestInit::new(); + init.set_integrity(sri); + window.fetch_with_str_and_init(url, &init) + } + None => window.fetch_with_str(url), + }; + let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?; if !resp.ok() { return Err(format!("HTTP {}", resp.status())); } @@ -242,16 +259,16 @@ impl WebShell { if let Some(files) = s.files.get(&family).cloned() && s.fetched.insert(family.clone()) { - fetch.extend(files.into_iter().map(|file| (family.clone(), file))); + fetch.extend(files.into_iter().map(|(file, integrity)| (family.clone(), file, integrity))); } } (fetch, std::mem::take(&mut s.arrived)) }; - for (family, file) in fetch { + for (family, file, integrity) in fetch { let served = self.served.clone(); let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { - match fetch_bytes(&file).await { + match fetch_bytes(&file, integrity.as_deref()).await { Ok(bytes) => { served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes)); ctx.request_repaint(); diff --git a/contributors/people.toml b/contributors/people.toml index 421770d66f..6fa921c60a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -66,3 +66,8 @@ sync_github_name = true [people.excited-raccoon] sync_github_name = true + +[people.hamada147] +real_name = "Ahmed Moussa" +display_name = "Ahmed Moussa" +sync_github_name = false diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 0b9eff4842..1dab0b8c48 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -218,6 +218,12 @@ impl FontDb { } let order = crate::cjk::ui_script_order(); self.fallbacks = fallback_candidates(&order).into_iter().filter(|f| c.family_id(f).is_some()).map(str::to_string).collect(); + // Served fonts that are a script's fallback (the manifest's `scripts`), once they arrived. + for f in crate::served::script_fallback_families() { + if c.family_id(&f).is_some() && !self.fallbacks.contains(&f) { + self.fallbacks.push(f); + } + } } /// Families available after the requested one (bundled default + installed coverage fonts). diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index df89f77c9a..76994673bb 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -519,6 +519,9 @@ impl Layouter { } let first_style = &out.styles[style_at(prange.start)]; let first_px = first_style.size_pt * k; + // Scripts the loaded fonts may lack (Arabic, Japanese, …): a host serving a font for them + // fetches it (`served`); it joins the fallback stack once it arrives. + crate::served::request_for_text(&ptext); let fallback: Vec = fonts.fallback_stack().map(str::to_string).collect(); let mut layout: Layout = { let mut b = self.lcx.ranged_builder(&mut fonts.fcx, &ptext, 1.0, false); diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 5d673e0969..b4a1f83a72 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -8,6 +8,14 @@ //! the host can serve, and the queue of families to fetch (picked in a font menu, or needed by a //! layout). The web shell does the fetching and registers what arrives with //! [`crate::FontDb::register_font_data`]. +//! +//! Script fallback: the manifest's `scripts` field says which scripts a font is for. The first +//! served family listed for a script the bundled fonts don't cover (Arabic, Hebrew, Devanagari, +//! Thai, Japanese, Chinese, Korean) is that script's fallback: text in that script asks for it +//! ([`request_for_text`], from layout), the font database puts it in the fallback stack once it +//! arrives, and the web shell asks up front for the scripts of the browser's languages +//! ([`request_for_languages`]). The manifest's SHA-256 becomes the fetch's Subresource Integrity +//! ([`ServedFont::integrity`]). use std::sync::{Mutex, PoisonError}; @@ -18,6 +26,11 @@ pub struct ServedFont { pub family: String, /// Path relative to the site root, e.g. `fonts/open-sans/OpenSans-Regular.ttf`. pub file: String, + /// ISO 15924 scripts the manifest lists for the file (`Arab`, `Jpan`, …); empty when absent. + pub scripts: Vec, + /// Subresource Integrity value (`sha256-`) from the manifest's SHA-256, when it has a + /// valid one: the browser rejects any other bytes. + pub integrity: Option, } /// Reads a manifest in craft-fonts' format: one font file per line, fields separated by ` | ` @@ -30,8 +43,10 @@ pub fn parse_manifest(text: &str) -> (Vec, Vec) { for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); match f.as_slice() { - [family, _style, file, ..] if !family.is_empty() && is_relative_path(file) => { - fonts.push(ServedFont { family: (*family).to_string(), file: (*file).to_string() }); + [family, _style, file, rest @ ..] if !family.is_empty() && is_relative_path(file) => { + let scripts = rest.first().map(|s| s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()).unwrap_or_default(); + let integrity = rest.get(3).and_then(|sha| sri(sha)); + fonts.push(ServedFont { family: (*family).to_string(), file: (*file).to_string(), scripts, integrity }); } [_, _, file, ..] if !is_relative_path(file) => skipped.push(format!("{line}: the file must be a relative path inside the site")), _ => skipped.push(format!("{line}: expected `family | style | file | …`")), @@ -46,16 +61,100 @@ fn is_relative_path(p: &str) -> bool { !p.is_empty() && !p.starts_with('/') && !p.contains(['\\', '?', '#', ':']) && p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") } +/// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers +/// Latin, Greek and Cyrillic) lack, as ISO 15924 codes. +const FALLBACK_SCRIPTS: &[&str] = &["Arab", "Hebr", "Deva", "Thai", "Jpan", "Hans", "Hant", "Kore"]; + +/// The script fallbacks in `fonts`: for each script in [`FALLBACK_SCRIPTS`], the first family in +/// manifest order that lists it, as `(script, family)` in order of first appearance. +pub fn script_fallbacks(fonts: &[ServedFont]) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + for f in fonts { + for s in &f.scripts { + if FALLBACK_SCRIPTS.contains(&s.as_str()) && !out.iter().any(|(have, _)| have == s) { + out.push((s.clone(), f.family.clone())); + } + } + } + out +} + +/// The fallback script of `c` (one of [`FALLBACK_SCRIPTS`]), or `None` for characters the bundled +/// fonts cover. Han follows `order` (the UI locale's CJK order), as the installed CJK fonts do. +pub fn script_of(c: char, order: &[crate::cjk::CjkScript; 4]) -> Option<&'static str> { + use crate::cjk::CjkScript; + if let Some(k) = crate::cjk::classify(c) { + return Some(match k.preferred(order) { + CjkScript::Japanese => "Jpan", + CjkScript::SimplifiedChinese => "Hans", + CjkScript::TraditionalChinese => "Hant", + CjkScript::Korean => "Kore", + }); + } + Some(match c as u32 { + 0x0590..=0x05FF | 0xFB1D..=0xFB4F => "Hebr", + 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF | 0x1EE00..=0x1EEFF => "Arab", + 0x0900..=0x097F | 0xA8E0..=0xA8FF => "Deva", + 0x0E00..=0x0E7F => "Thai", + _ => return None, + }) +} + +/// The fallback script a browser language (`ar-EG`, `zh-Hant-TW`, …) is written in, if any. +pub fn language_script(tag: &str) -> Option<&'static str> { + let tag = tag.trim().to_ascii_lowercase().replace('_', "-"); + let mut parts = tag.split('-'); + let lang = parts.next()?; + let rest: Vec<&str> = parts.collect(); + Some(match lang { + "ar" | "fa" | "ur" | "ps" | "ckb" | "sd" | "ug" => "Arab", + "he" | "iw" | "yi" => "Hebr", + "hi" | "mr" | "ne" | "sa" => "Deva", + "th" => "Thai", + "ja" => "Jpan", + "ko" => "Kore", + "zh" if rest.iter().any(|r| matches!(*r, "hant" | "tw" | "hk" | "mo")) => "Hant", + "zh" => "Hans", + _ => return None, + }) +} + +/// The Subresource Integrity value (`sha256-`) for a 64-digit hex SHA-256. +fn sri(hex: &str) -> Option { + if hex.len() != 64 { + return None; + } + let bytes: Option> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); + Some(format!("sha256-{}", base64(&bytes?))) +} + +/// Standard base64 (RFC 4648, with padding). +fn base64(data: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = |i: usize| u32::from(chunk.get(i).copied().unwrap_or(0)); + let n = (b(0) << 16) | (b(1) << 8) | b(2); + for k in 0..4 { + let six = ((n >> (18 - 6 * k)) & 63) as usize; + s.push(if k <= chunk.len() { TABLE.get(six).map_or('=', |&c| char::from(c)) } else { '=' }); + } + } + s +} + struct State { /// Families the host can serve, installed or not. families: Vec, + /// Script fallbacks, `(script, family)`: the first one per script is used. + fallbacks: Vec<(String, String)>, /// Families to fetch, not yet taken by [`take_requests`]. requested: Vec, /// Bumped when `families` changes, so font menus refresh. generation: u64, } -static STATE: Mutex = Mutex::new(State { families: Vec::new(), requested: Vec::new(), generation: 0 }); +static STATE: Mutex = Mutex::new(State { families: Vec::new(), fallbacks: Vec::new(), requested: Vec::new(), generation: 0 }); fn state() -> std::sync::MutexGuard<'static, State> { STATE.lock().unwrap_or_else(PoisonError::into_inner) @@ -75,6 +174,77 @@ pub fn add_families(families: impl IntoIterator) { } } +/// Adds the fonts of a manifest: their families (as [`add_families`]) and their script fallbacks. +pub fn add_fonts(fonts: &[ServedFont]) { + add_families(fonts.iter().map(|f| f.family.clone())); + let mut s = state(); + for pair in script_fallbacks(fonts) { + if !s.fallbacks.contains(&pair) { + s.fallbacks.push(pair); + } + } +} + +/// The served families that are some script's fallback (the font database adds the ones it has +/// to its fallback stack). +pub fn script_fallback_families() -> Vec { + let mut v: Vec = Vec::new(); + for (_, f) in &state().fallbacks { + if !v.contains(f) { + v.push(f.clone()); + } + } + v +} + +/// The fallback family for `script`, if the host serves one. +fn fallback_for(fallbacks: &[(String, String)], script: &str) -> Option { + fallbacks.iter().find(|(s, _)| s == script).map(|(_, f)| f.clone()) +} + +/// Asks for the served fallback families `text` needs: one per script in it that the bundled +/// fonts lack (called by layout; cheap when the host serves no script fallbacks). +pub fn request_for_text(text: &str) { + let fallbacks = state().fallbacks.clone(); + if fallbacks.is_empty() { + return; + } + let order = crate::cjk::ui_script_order(); + let mut asked: Vec<&'static str> = Vec::new(); + for c in text.chars() { + if let Some(script) = script_of(c, &order) + && !asked.contains(&script) + { + asked.push(script); + if let Some(family) = fallback_for(&fallbacks, script) { + request(&family); + } + } + } +} + +/// Does `text` contain characters whose served fallback is `family`? (Type layers drawn before it +/// arrived are re-rendered.) +pub fn falls_back_to(text: &str, family: &str) -> bool { + let fallbacks = state().fallbacks.clone(); + if !fallbacks.iter().any(|(_, f)| f == family) { + return false; + } + let order = crate::cjk::ui_script_order(); + text.chars().any(|c| script_of(c, &order).and_then(|s| fallback_for(&fallbacks, s)).is_some_and(|f| f == family)) +} + +/// Asks up front for the fallback families of the scripts `languages` (the browser's, e.g. +/// `navigator.languages`) are written in, so text in them never waits for its font. +pub fn request_for_languages<'a>(languages: impl IntoIterator) { + let fallbacks = state().fallbacks.clone(); + for script in languages.into_iter().filter_map(language_script) { + if let Some(family) = fallback_for(&fallbacks, script) { + request(&family); + } + } +} + /// Families the host can serve, installed or not (for font menus). pub fn families() -> Vec { state().families.clone() @@ -113,8 +283,13 @@ mod tests { assert_eq!( fonts, [ - ServedFont { family: "BIZ UDPGothic".into(), file: "fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf".into() }, - ServedFont { family: "Open Sans".into(), file: "fonts/open-sans/OpenSans[wdth,wght].ttf".into() }, + ServedFont { + family: "BIZ UDPGothic".into(), + file: "fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf".into(), + scripts: vec!["Jpan".into(), "Latn".into()], + integrity: None, // "258d" is not a SHA-256 + }, + ServedFont { family: "Open Sans".into(), file: "fonts/open-sans/OpenSans[wdth,wght].ttf".into(), scripts: vec![], integrity: None }, ] ); assert!(skipped.is_empty(), "{skipped:?}"); @@ -133,7 +308,7 @@ mod tests { G | Regular | fonts/g.ttf?v=1\n\ Lato | Bold | fonts/lato/Lato-Bold.ttf\n"; let (fonts, skipped) = parse_manifest(text); - assert_eq!(fonts, [ServedFont { family: "Lato".into(), file: "fonts/lato/Lato-Bold.ttf".into() }]); + assert_eq!(fonts, [ServedFont { family: "Lato".into(), file: "fonts/lato/Lato-Bold.ttf".into(), scripts: vec![], integrity: None }]); assert_eq!(skipped.len(), 9, "{skipped:?}"); } @@ -163,5 +338,96 @@ mod tests { let l = crate::TextEngine::new().layout(&t, 72.0); assert_eq!(l.glyphs.len(), 2); assert!(take_requests().iter().any(|f| f == served)); + + // Script fallback: text in a script the bundled fonts lack asks for the served family + // that covers it (Hebrew here; other tests use other scripts, the state is shared). + let hebrew = "Served Test Hebrew"; + let manifest = format!( + "{hebrew} | Regular | fonts/h/H.ttf | Hebr,Latn | OFL-1.1 | fonts/h/OFL.txt | {} | src\n", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + add_fonts(&parse_manifest(&manifest).0); + assert!(families().iter().any(|f| f == hebrew), "add_fonts lists the family too"); + assert!(script_fallback_families().iter().any(|f| f == hebrew)); + let _ = take_requests(); + request_for_text("Latin only, 123"); + assert!(!take_requests().iter().any(|f| f == hebrew), "Latin text needs no served fallback"); + request_for_text("Shalom שלום"); + assert!(take_requests().iter().any(|f| f == hebrew), "Hebrew text asks for the Hebrew family"); + assert!(falls_back_to("שלום", hebrew)); + assert!(!falls_back_to("hello", hebrew)); + // Startup preloading: a browser language using that script asks for it up front. + request_for_languages(["he-IL"]); + assert!(take_requests().iter().any(|f| f == hebrew)); + request_for_languages(["en-US", "fr"]); + assert!(!take_requests().iter().any(|f| f == hebrew)); + // A layout of Hebrew text in the default family asks for it too. + let t = photocraft_doc::TextLayer { text: "שלום".into(), size_pt: 12.0, ..Default::default() }; + let _ = crate::TextEngine::new().layout(&t, 72.0); + assert!(take_requests().iter().any(|f| f == hebrew), "layout asks for the script fallback"); + } + + #[test] + fn manifest_scripts_and_sha256_become_scripts_and_integrity() { + let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let (fonts, _) = parse_manifest(&format!("Noto Sans Arabic | Regular | fonts/n/N.ttf | Arab | OFL-1.1 | fonts/n/OFL.txt | {sha} | src\n")); + assert_eq!(fonts[0].scripts, ["Arab"]); + assert_eq!(fonts[0].integrity.as_deref(), Some("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")); + } + + #[test] + fn the_first_served_family_per_script_is_its_fallback() { + let text = "Noto Sans Arabic | Regular | fonts/a.ttf | Arab\n\ + Cairo | Regular | fonts/c.ttf | Arab,Latn\n\ + Lobster | Regular | fonts/l.ttf | Latn,Cyrl\n\ + BIZ UDPGothic | Regular | fonts/b.ttf | Jpan,Latn\n"; + let (fonts, _) = parse_manifest(text); + assert_eq!( + script_fallbacks(&fonts), + [("Arab".to_string(), "Noto Sans Arabic".to_string()), ("Jpan".to_string(), "BIZ UDPGothic".to_string())], + "Latin and Cyrillic are the bundled fonts' job" + ); + } + + #[test] + fn characters_map_to_the_scripts_served_fonts_cover() { + use crate::cjk::CjkScript::*; + let ja = [Japanese, SimplifiedChinese, TraditionalChinese, Korean]; + let zh = [SimplifiedChinese, TraditionalChinese, Japanese, Korean]; + for (c, want) in [ + ('ب', Some("Arab")), + ('ﻻ', Some("Arab")), + ('ש', Some("Hebr")), + ('क', Some("Deva")), + ('ก', Some("Thai")), + ('あ', Some("Jpan")), + ('한', Some("Kore")), + ] { + assert_eq!(script_of(c, &ja), want, "{c}"); + } + for c in ['a', 'Ж', 'Ω', '1', ' ', '€'] { + assert_eq!(script_of(c, &ja), None, "{c}: the bundled fonts cover it"); + } + assert_eq!(script_of('漢', &ja), Some("Jpan")); + assert_eq!(script_of('漢', &zh), Some("Hans")); + } + + #[test] + fn browser_languages_map_to_scripts() { + for (tag, want) in [ + ("ar", Some("Arab")), + ("ar-EG", Some("Arab")), + ("fa-IR", Some("Arab")), + ("he", Some("Hebr")), + ("ja-JP", Some("Jpan")), + ("zh-CN", Some("Hans")), + ("zh-TW", Some("Hant")), + ("zh-Hant-HK", Some("Hant")), + ("ko", Some("Kore")), + ("en-US", None), + ("ru", None), + ] { + assert_eq!(language_script(tag), want, "{tag}"); + } } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 24a5bcbaf0..c9a78259e7 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1176,3 +1176,16 @@ fn thai_fallback_candidates_cover_every_platform() { } } } + +#[test] +fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { + let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else { + eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); + return; + }; + crate::served::add_fonts(&crate::served::parse_manifest("Cairo | Regular | fonts/cairo/Cairo.ttf | Arab,Latn\n").0); + let mut db = fonts::FontDb::new(); + assert!(!db.fallback_stack().any(|f| f == "Cairo"), "not before it arrives"); + db.register_font_data(cairo.bytes.to_vec()); + assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::>()); +} diff --git a/crates/ui-egui/src/served_fonts.rs b/crates/ui-egui/src/served_fonts.rs index 342e3f19e8..6273383c75 100644 --- a/crates/ui-egui/src/served_fonts.rs +++ b/crates/ui-egui/src/served_fonts.rs @@ -5,7 +5,7 @@ use std::sync::{Mutex, PoisonError}; use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer}; -pub use photocraft_text::served::{ServedFont, add_families, parse_manifest, take_requests}; +pub use photocraft_text::served::{ServedFont, add_families, add_fonts, parse_manifest, request_for_languages, take_requests}; use crate::PhotocraftApp; @@ -13,7 +13,8 @@ use crate::PhotocraftApp; static PENDING: Mutex> = Mutex::new(Vec::new()); /// Registers delivered font files (`(family, bytes)`, the family as the manifest lists it) and -/// re-renders the type layers that use those families and show our own layout. A PSD layer still +/// re-renders the type layers that use those families, or whose text falls back to them (a script +/// fallback, e.g. Arabic set in Inter), and show our own layout. A PSD layer still /// showing Photoshop's pixels (not edited yet) keeps them, as it does when its font is installed /// from the start. Call it every frame: it also retries the layers of a document that was busy. pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec)>) { @@ -25,7 +26,7 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec)>) { for st in app.session.documents() { for (_, _, l) in st.doc.walk() { if let LayerContent::Text(t) = &l.content - && uses_family(t, &families) + && redraws_for(t, &families) && crate::type_tool::shows_own_layout(&st.doc, t) && !layers.contains(&(st.doc.id, l.id)) { @@ -47,6 +48,12 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec)>) { } } +/// Should the layer be redrawn when `families` arrive: it uses one of them, or its text is in a +/// script one of them is the served fallback for? +fn redraws_for(t: &TextLayer, families: &[String]) -> bool { + uses_family(t, families) || families.iter().any(|f| photocraft_text::served::falls_back_to(&t.text, f)) +} + /// Does the layer use one of `families`, by name or through the PostScript name a PSD gave? fn uses_family(t: &TextLayer, families: &[String]) -> bool { t.char_runs().iter().any(|r| { @@ -71,4 +78,15 @@ mod tests { }]; assert!(uses_family(&psd, &["Lato".into()])); } + + #[test] + fn a_layer_in_a_script_redraws_when_that_scripts_fallback_arrives() { + // Thai: other tests use other scripts (the served catalog is process-wide). + let thai = "Served Test Thai"; + photocraft_text::served::add_fonts(&parse_manifest(&format!("{thai} | Regular | fonts/t/T.ttf | Thai\n")).0); + let layer = |text: &str| TextLayer { text: text.into(), font_family: "Inter".into(), ..Default::default() }; + assert!(redraws_for(&layer("สวัสดี"), &[thai.into()]), "Thai text set in Inter falls back to it"); + assert!(!redraws_for(&layer("hello"), &[thai.into()])); + assert!(redraws_for(&layer("hello"), &["Inter".into()]), "and a layer still redraws for its own family"); + } }