fix(Text): served fonts become script fallbacks on the web (#2109)

A host's font manifest can now list the scripts each font covers (`scripts` field). The first served family for a script the bundled fonts lack (Arabic, Hebrew, Devanagari, Thai, Japanese, Chinese, Korean) becomes that script's fallback. When a layout contains text in that script, it requests the family. The font database adds the family to the fallback stack once it arrives, and type layers drawn before then are re-rendered.

The web shell also fetches the fallbacks for the browser's languages (`navigator.languages`) at startup, so an Arabic reader gets the Arabic font before typing anything.

The manifest's SHA-256 is now turned into a Subresource Integrity value (`sha256-<base64>`) and passed with the font fetch, so the browser rejects any file whose bytes don't match.

Also adds a contributor entry for hamada147 to `contributors/people.toml`.

Signed-off-by: Ahmed Moussa <[email protected]>
This commit is contained in:
Ahmed Moussa authored and GitHub committed 2026-10-09 20:04:04 -04:00
1 parent 1330899aca
commit 1cfc56db56
8 files changed
+352 -24

No files matched your search

+1 -1
View File
@@ -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 }
+31 -14
View File
@@ -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<String, Vec<String>>,
/// Font files by family, each with its Subresource Integrity value when the manifest has one.
files: HashMap<String, Vec<(String, Option<String>)>>,
/// Families fetched or being fetched: each one once per page load.
fetched: HashSet<String>,
/// 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<String, Vec<String>> = HashMap::new();
for f in fonts {
files.entry(f.family).or_default().push(f.file);
let mut files: HashMap<String, Vec<(String, Option<String>)>> = 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<String> = 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<Vec<u8>, String> {
/// The browser's preferred languages (`navigator.languages`, e.g. `["ar-EG", "en"]`).
fn browser_languages() -> Vec<String> {
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<Vec<u8>, 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();
+5
View File
@@ -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
+6
View File
@@ -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).
+3
View File
@@ -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<String> = fonts.fallback_stack().map(str::to_string).collect();
let mut layout: Layout<RunBrush> = {
let mut b = self.lcx.ranged_builder(&mut fonts.fcx, &ptext, 1.0, false);
+272 -6
View File
@@ -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<String>,
/// Subresource Integrity value (`sha256-<base64>`) from the manifest's SHA-256, when it has a
/// valid one: the browser rejects any other bytes.
pub integrity: Option<String>,
}
/// 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<ServedFont>, Vec<String>) {
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-<base64>`) for a 64-digit hex SHA-256.
fn sri(hex: &str) -> Option<String> {
if hex.len() != 64 {
return None;
}
let bytes: Option<Vec<u8>> = (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<String>,
/// 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<String>,
/// Bumped when `families` changes, so font menus refresh.
generation: u64,
}
static STATE: Mutex<State> = Mutex::new(State { families: Vec::new(), requested: Vec::new(), generation: 0 });
static STATE: Mutex<State> = 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<Item = String>) {
}
}
/// 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<String> {
let mut v: Vec<String> = 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<String> {
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<Item = &'a str>) {
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<String> {
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}");
}
}
}
+13
View File
@@ -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::<Vec<_>>());
}
+21 -3
View File
@@ -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<Vec<(DocId, LayerId)>> = 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<u8>)>) {
@@ -25,7 +26,7 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec<u8>)>) {
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<u8>)>) {
}
}
/// 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");
}
}