Files
photocraft/crates/text/src/served.rs
T

456 lines
20 KiB
Rust

//! Fonts a host serves next to the app, fetched on demand (the web build: no system fonts, and
//! the wasm has no room for more embedded ones, see `build.rs`).
//!
//! The host lists them in `fonts/manifest.txt` next to `index.html`, in craft-fonts' manifest
//! format (`family | style | file | …`, the file relative to the site root), so a craft-fonts
//! checkout's `fonts/` folder works as it is and any other font needs one line. This module is
//! the part that doesn't depend on the platform: reading the manifest, the catalog of families
//! 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, Khmer, Lao, Burmese, 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};
/// One font file listed in a served font manifest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServedFont {
/// The family the file registers as (its typographic family name, `name` ID 16, else ID 1).
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 ` | `
/// (`family | style | file | scripts | licence | licence file | sha256 | source`); `#` comments
/// and blank lines are ignored. Only family and file are needed here, so a line needs at least
/// `family | style | file`. Returns the fonts and, for each line that was skipped, why.
pub fn parse_manifest(text: &str) -> (Vec<ServedFont>, Vec<String>) {
let mut fonts = Vec::new();
let mut skipped = Vec::new();
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, 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 | …`")),
}
}
(fonts, skipped)
}
/// A relative path that stays inside the site: no scheme, no leading `/`, no `..` segment, no
/// query or fragment.
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", "Khmr", "Laoo", "Mymr", "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",
0x1780..=0x17FF | 0x19E0..=0x19FF => "Khmr",
0x0E80..=0x0EFF => "Laoo",
0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F => "Mymr",
_ => 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",
"km" => "Khmr",
"lo" => "Laoo",
"my" => "Mymr",
"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 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
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(), fallbacks: Vec::new(), requested: Vec::new(), generation: 0 });
fn state() -> std::sync::MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Adds families the host can serve (the web shell, after reading the manifest).
pub fn add_families(families: impl IntoIterator<Item = String>) {
let mut s = state();
let before = s.families.len();
for f in families {
if !s.families.contains(&f) {
s.families.push(f);
}
}
if s.families.len() != before {
s.generation = s.generation.wrapping_add(1);
}
}
/// 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()
}
/// Changes whenever [`families`] does.
pub fn generation() -> u64 {
state().generation
}
/// Asks for a family to be fetched: picked in a font menu, or needed by a layout that doesn't
/// have it. Families the host doesn't serve are ignored; asking again is harmless.
pub fn request(family: &str) {
let mut s = state();
if s.families.iter().any(|f| f == family) && !s.requested.iter().any(|f| f == family) {
s.requested.push(family.to_string());
}
}
/// The families asked for since the last call (the web shell fetches each one once).
pub fn take_requests() -> Vec<String> {
std::mem::take(&mut state().requested)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_craft_fonts_manifest_lines() {
let text = "# Font manifest for the Crafting Apps.\n\
\n\
BIZ UDPGothic | Regular | fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf | Jpan,Latn | OFL-1.1 | fonts/biz-ud-pgothic/OFL.txt | 258d | https://example.org\n\
Open Sans | Regular | fonts/open-sans/OpenSans[wdth,wght].ttf\n";
let (fonts, skipped) = parse_manifest(text);
assert_eq!(
fonts,
[
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:?}");
}
#[test]
fn skips_malformed_lines_and_paths_outside_the_site() {
let text = "Lato | Regular\n\
| Regular | fonts/x.ttf\n\
A | Regular | /fonts/a.ttf\n\
B | Regular | ../b.ttf\n\
C | Regular | fonts/../../c.ttf\n\
D | Regular | https://example.org/d.ttf\n\
E | Regular | fonts//e.ttf\n\
F | Regular | fonts\\f.ttf\n\
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(), scripts: vec![], integrity: None }]);
assert_eq!(skipped.len(), 9, "{skipped:?}");
}
/// One test for the whole queue: the catalog and the queue are process-wide, and parallel
/// tests taking each other's requests would be flaky.
#[test]
fn requests_are_limited_to_the_catalog_and_reach_layout() {
let served = "Served Test Sans";
let g = generation();
add_families([served.to_string(), served.to_string()]);
assert_eq!(families().iter().filter(|f| *f == served).count(), 1);
assert_ne!(generation(), g, "the catalog changed");
let g = generation();
add_families([served.to_string()]);
assert_eq!(generation(), g, "nothing new");
request("Not Served Test Sans");
request(served);
request(served);
let taken = take_requests();
assert_eq!(taken.iter().filter(|f| *f == served).count(), 1, "{taken:?}");
assert!(!taken.iter().any(|f| f == "Not Served Test Sans"));
// A layout that needs a served family the database lacks asks for it (and is drawn with
// the fallback meanwhile).
let t = photocraft_doc::TextLayer { text: "Hi".into(), font_family: served.into(), size_pt: 12.0, ..Default::default() };
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("Khmr")),
('ៗ', Some("Khmr")),
('ລ', Some("Laoo")),
('မ', Some("Mymr")),
('ၡ', Some("Mymr")),
('あ', 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")),
("km-KH", Some("Khmr")),
("lo", Some("Laoo")),
("my-MM", Some("Mymr")),
("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}");
}
}
#[test]
fn sri_rejects_non_hex_digits() {
let ok = "0".repeat(64);
assert!(sri(&ok).is_some());
let plus = format!("+f{}", "0".repeat(62));
assert!(sri(&plus).is_none());
}
}