mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Web: fonts served next to the app, loaded on demand (#1875)
A host lists fonts in fonts/manifest.txt beside index.html (craft-fonts' manifest format, paths kept inside the site); the web shell lists those families in the font menus and fetches a family when it is picked or a layout needs it, then re-renders the affected type layers without a history step. No manifest changes nothing; package.sh copies one in with PHOTOCRAFT_WEB_FONTS_DIR. The style menu also lists the weights of a variable font's wght axis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,4 +34,4 @@ 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"] }
|
||||
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"] }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::served_fonts;
|
||||
use photocraft_ui_egui::theme::ThemeKind;
|
||||
use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
@@ -21,6 +23,23 @@ const OPEN_EXTS: &[&str] = &[
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
/// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest
|
||||
/// format, each file relative to the site root. No manifest (a 404) means no served fonts.
|
||||
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>>,
|
||||
/// Families fetched or being fetched: each one once per page load.
|
||||
fetched: HashSet<String>,
|
||||
/// Downloaded files waiting for the next frame: (family, bytes).
|
||||
arrived: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
type Served = Arc<Mutex<ServedFonts>>;
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -49,6 +68,8 @@ pub fn start() {
|
||||
Box::new(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let inbox: Inbox = Arc::default();
|
||||
let served: Served = Arc::default();
|
||||
load_font_manifest(served.clone(), cc.egui_ctx.clone());
|
||||
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone()));
|
||||
listen_pen(&pen_target, app.stylus.feed.clone());
|
||||
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
|
||||
@@ -60,7 +81,7 @@ pub fn start() {
|
||||
}
|
||||
let unsaved = Arc::new(AtomicBool::new(false));
|
||||
guard_unload(unsaved.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, unsaved }))
|
||||
Ok(Box::new(WebShell { app, inbox, unsaved, served }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -125,6 +146,45 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reads the served font manifest, if the host has one, and lists its families in the font menus.
|
||||
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 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('<') {
|
||||
return;
|
||||
}
|
||||
let (fonts, skipped) = served_fonts::parse_manifest(&text);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// GET `url` (relative to the page) and read the whole body.
|
||||
async fn fetch_bytes(url: &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")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let body = JsFuture::from(resp.array_buffer().map_err(js)?).await.map_err(js)?;
|
||||
Ok(js_sys::Uint8Array::new(&body).to_vec())
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
|
||||
/// so the app's own drop path can't handle them) and feed them through the inbox.
|
||||
struct WebShell {
|
||||
@@ -132,6 +192,41 @@ struct WebShell {
|
||||
inbox: Inbox,
|
||||
/// Read by the `beforeunload` listener ([`guard_unload`]).
|
||||
unsaved: Arc<AtomicBool>,
|
||||
served: Served,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
|
||||
/// installs the files that arrived.
|
||||
fn serve_fonts(&mut self, ctx: &egui::Context) {
|
||||
let requested = served_fonts::take_requests();
|
||||
let (fetch, arrived) = {
|
||||
let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut fetch = Vec::new();
|
||||
for family in requested {
|
||||
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, std::mem::take(&mut s.arrived))
|
||||
};
|
||||
for (family, file) in fetch {
|
||||
let served = self.served.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match fetch_bytes(&file).await {
|
||||
Ok(bytes) => {
|
||||
served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't fetch served font {file}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
served_fonts::install(&mut self.app, arrived);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -151,6 +246,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.serve_fonts(ctx);
|
||||
self.app.logic(ctx, frame);
|
||||
self.unsaved.store(self.app.has_unsaved_work(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -512,6 +512,48 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-renders the pixels of type layers whose font arrived after they were drawn (the web
|
||||
/// build fetches served fonts on demand, `photocraft_text::served`). The layers' model is
|
||||
/// unchanged, so this is no history step and leaves a clean document clean. Unknown documents
|
||||
/// and layers are skipped. A document a background job is computing from must not move under
|
||||
/// it: its layers are returned, to try again later.
|
||||
pub fn refresh_type_layers(&mut self, layers: &[(photocraft_doc::DocId, LayerId)]) -> Vec<(photocraft_doc::DocId, LayerId)> {
|
||||
let mut busy = Vec::new();
|
||||
let mut docs: Vec<photocraft_doc::DocId> = Vec::new();
|
||||
for (d, _) in layers {
|
||||
if !docs.contains(d) {
|
||||
docs.push(*d);
|
||||
}
|
||||
}
|
||||
for doc_id in docs {
|
||||
let mine = layers.iter().filter(|(d, _)| *d == doc_id);
|
||||
if self.job_on(doc_id).is_some() {
|
||||
busy.extend(mine);
|
||||
continue;
|
||||
}
|
||||
let Some(st) = self.docs.iter_mut().find(|st| st.doc.id == doc_id) else { continue };
|
||||
let snapshot = st.doc.clone();
|
||||
let mut doc = (*snapshot).clone();
|
||||
let mut changed = false;
|
||||
for (_, id) in mine {
|
||||
if let Some(photocraft_doc::Layer { content: photocraft_doc::LayerContent::Text(t), .. }) = doc.layer_mut(*id) {
|
||||
type_cmds::refresh(&snapshot, t);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let clean = st.saved_revision == st.revision;
|
||||
st.doc = Arc::new(doc);
|
||||
st.revision += 1;
|
||||
if clean {
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
st.last_damage = None;
|
||||
}
|
||||
}
|
||||
busy
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
// A background job is computing from the current state: it must not move under it.
|
||||
if self.active_job().is_some() {
|
||||
|
||||
@@ -819,6 +819,32 @@ mod tests {
|
||||
assert_eq!(text_layer(&s, id).runs, before.runs);
|
||||
}
|
||||
|
||||
/// A font that arrives after the layer was drawn (served fonts on the web) re-renders it with
|
||||
/// no history step, and a clean document stays clean.
|
||||
#[test]
|
||||
fn refreshing_type_layers_takes_no_history_step() {
|
||||
let mut s = session();
|
||||
let id = LayerId(s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap());
|
||||
let doc_id = s.active().unwrap().doc.id;
|
||||
// Stale pixels, and a document saved in this state.
|
||||
{
|
||||
let st = s.active_mut().unwrap();
|
||||
let Some(Layer { content: LayerContent::Text(t), .. }) = Arc::make_mut(&mut st.doc).layer_mut(id) else { panic!("not a type layer") };
|
||||
t.cache = None;
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
let rev = s.active().unwrap().revision;
|
||||
let unknown = [(doc_id, LayerId(9999)), (photocraft_doc::DocId(u64::MAX), id)];
|
||||
assert!(s.refresh_type_layers(&[&[(doc_id, id)][..], &unknown].concat()).is_empty());
|
||||
assert!(text_layer(&s, id.0).cache.is_some());
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.revision > rev);
|
||||
assert_eq!(st.saved_revision, st.revision, "still clean");
|
||||
// The only history step is still the creation.
|
||||
assert!(s.undo());
|
||||
assert!(s.active().unwrap().doc.layer(id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_renders_and_is_undoable() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//! stay under the 24 MiB gate in `packaging/web/package.sh` (#237; Cloudflare's per-file cap is
|
||||
//! 25 MiB). Measured 2026-10-06 (`trunk build --release`): 24,190,076 bytes
|
||||
//! without craft-fonts, 28,867,488 with only the UI face (BIZ UDPGothic Regular) embedded, over
|
||||
//! the 25,165,824-byte gate. The web build keeps no Japanese font until craft-fonts can be
|
||||
//! served next to the wasm instead of inside it.
|
||||
//! the 25,165,824-byte gate. The web build loads fonts served next to the wasm instead, on
|
||||
//! demand (`fonts/manifest.txt`, see `served.rs` and packaging/web/README.md › Fonts).
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use parley::FontContext;
|
||||
use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache};
|
||||
@@ -53,6 +54,16 @@ pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static s
|
||||
v
|
||||
}
|
||||
|
||||
/// Bumped whenever font data is registered: fonts can arrive after start (the web build fetches
|
||||
/// served fonts on demand, [`crate::served`]), so lists derived from the database, such as font
|
||||
/// menus, compare it to know when to refresh.
|
||||
static GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Changes whenever font data is registered in any [`FontDb`].
|
||||
pub fn generation() -> u64 {
|
||||
GENERATION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One face in the database (for font menus).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FaceInfo {
|
||||
@@ -169,6 +180,7 @@ impl FontDb {
|
||||
names.sort_by_key(|n| (is_hidden_family(n), n.to_lowercase()));
|
||||
self.ps_cache.clear();
|
||||
self.refresh_generics();
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
names
|
||||
}
|
||||
|
||||
|
||||
@@ -427,6 +427,10 @@ impl Layouter {
|
||||
s.italic = f.italic;
|
||||
}
|
||||
}
|
||||
// Drawn with a fallback for now; a host that serves this family fetches it (`served`).
|
||||
if !fonts.has_family(&s.font_family) {
|
||||
crate::served::request(&s.font_family);
|
||||
}
|
||||
out.styles.push(s);
|
||||
}
|
||||
let run_starts: Vec<usize> = runs
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod psd;
|
||||
pub mod psd_styles;
|
||||
pub mod raster;
|
||||
pub mod render;
|
||||
pub mod served;
|
||||
pub mod spell;
|
||||
pub mod warp;
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! 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`].
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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, ..] if !family.is_empty() && is_relative_path(file) => {
|
||||
fonts.push(ServedFont { family: (*family).to_string(), file: (*file).to_string() });
|
||||
}
|
||||
[_, _, 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 != "..")
|
||||
}
|
||||
|
||||
struct State {
|
||||
/// Families the host can serve, installed or not.
|
||||
families: Vec<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 });
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() },
|
||||
ServedFont { family: "Open Sans".into(), file: "fonts/open-sans/OpenSans[wdth,wght].ttf".into() },
|
||||
]
|
||||
);
|
||||
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() }]);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,14 @@ fn bundled_fonts_cover_latin() {
|
||||
assert_eq!(e.fonts.faces("Inter").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registering_fonts_moves_the_generation() {
|
||||
let mut e = TextEngine::new();
|
||||
let g = fonts::generation();
|
||||
e.fonts.register_font_data(fonts::INTER_REGULAR.to_vec());
|
||||
assert!(fonts::generation() > g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_are_stable_and_scale_with_dpi() {
|
||||
let mut e = TextEngine::new();
|
||||
|
||||
@@ -119,6 +119,7 @@ pub mod rotate_view;
|
||||
pub mod rulers;
|
||||
pub mod screen_picker;
|
||||
pub mod scrollbars;
|
||||
pub mod served_fonts;
|
||||
pub mod shortcut_dispatch;
|
||||
pub mod shortcuts;
|
||||
mod sizing;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Fonts a host serves next to the app, for the platform shell that fetches them (the web build;
|
||||
//! see `photocraft_text::served`): reading the manifest, the families to fetch, and installing
|
||||
//! what arrived, which registers the files and re-renders the type layers drawn without them.
|
||||
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer};
|
||||
pub use photocraft_text::served::{ServedFont, add_families, parse_manifest, take_requests};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Layers whose document was busy with a background job when their font arrived.
|
||||
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
|
||||
/// 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>)>) {
|
||||
let mut layers = std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
if !delivered.is_empty() {
|
||||
let families: Vec<String> = delivered.iter().map(|(f, _)| f.clone()).collect();
|
||||
// Decided before the fonts are in: "shows our own layout" compares the layer's pixels
|
||||
// with what the engine draws now, that is with the fallback font.
|
||||
for st in app.session.documents() {
|
||||
for (_, _, l) in st.doc.walk() {
|
||||
if let LayerContent::Text(t) = &l.content
|
||||
&& uses_family(t, &families)
|
||||
&& crate::type_tool::shows_own_layout(&st.doc, t)
|
||||
&& !layers.contains(&(st.doc.id, l.id))
|
||||
{
|
||||
layers.push((st.doc.id, l.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
for (family, bytes) in delivered {
|
||||
let added = eng.fonts.register_font_data(bytes);
|
||||
if !added.contains(&family) {
|
||||
log::warn!("a served font file listed as {family:?} registers {added:?}; the family in fonts/manifest.txt must be the font's own");
|
||||
}
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
let busy = app.session.refresh_type_layers(&layers);
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(busy);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
families.contains(&r.style.font_family)
|
||||
|| r.style.postscript_name.as_deref().is_some_and(|ps| families.contains(&photocraft_text::fonts::guess_from_postscript(ps).family))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_layer_uses_a_family_by_name_or_postscript_name() {
|
||||
let t = TextLayer { text: "Hi".into(), font_family: "Open Sans".into(), ..Default::default() };
|
||||
assert!(uses_family(&t, &["Open Sans".into()]));
|
||||
assert!(!uses_family(&t, &["Lato".into()]));
|
||||
let mut psd = TextLayer { text: "Hi".into(), font_family: "Inter".into(), ..Default::default() };
|
||||
psd.runs = vec![photocraft_doc::text::TextRun {
|
||||
len: 2,
|
||||
style: photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Lato-Bold".into()), ..Default::default() },
|
||||
}];
|
||||
assert!(uses_family(&psd, &["Lato".into()]));
|
||||
}
|
||||
}
|
||||
@@ -509,6 +509,7 @@ fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String) -> bool {
|
||||
for f in crate::type_tool::families().iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)) {
|
||||
if ui.selectable_label(*f == shown, f).clicked() {
|
||||
*current = f.clone();
|
||||
photocraft_text::served::request(f);
|
||||
changed = true;
|
||||
ui.data_mut(|d| d.remove::<String>(search_id));
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<LayerId> {
|
||||
/// True when the layer's pixels are what our engine draws for it. A PSD's type layer keeps
|
||||
/// Photoshop's pixels until it is edited, and with substituted fonts or a different line layout
|
||||
/// they sit somewhere else than the glyphs the caret and selection are placed on.
|
||||
fn shows_own_layout(doc: &Document, t: &TextLayer) -> bool {
|
||||
pub(crate) fn shows_own_layout(doc: &Document, t: &TextLayer) -> bool {
|
||||
let Some(cache) = &t.cache else { return false };
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let ours = eng.render(t, doc.resolution_dpi, doc.pixel_format()).1.surface.content_bounds();
|
||||
@@ -676,10 +676,28 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
|
||||
}
|
||||
}
|
||||
|
||||
/// Font families (bundled + system), cached for the process.
|
||||
pub fn families() -> &'static [String] {
|
||||
static FAMILIES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
FAMILIES.get_or_init(|| photocraft_text::shared().lock().map(|mut e| e.fonts.families()).unwrap_or_default())
|
||||
/// Font families: bundled, system, and those the host serves (`photocraft_text::served`, fetched
|
||||
/// when picked). Cached until fonts are registered or the served catalog changes.
|
||||
pub fn families() -> Arc<Vec<String>> {
|
||||
type Cached = Option<((u64, u64), Arc<Vec<String>>)>;
|
||||
static FAMILIES: std::sync::Mutex<Cached> = std::sync::Mutex::new(None);
|
||||
let key = (photocraft_text::fonts::generation(), photocraft_text::served::generation());
|
||||
let mut cached = FAMILIES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some((k, v)) = cached.as_ref()
|
||||
&& *k == key
|
||||
{
|
||||
return v.clone();
|
||||
}
|
||||
let mut v = photocraft_text::shared().lock().map(|mut e| e.fonts.families()).unwrap_or_default();
|
||||
for f in photocraft_text::served::families() {
|
||||
if !v.contains(&f) {
|
||||
v.push(f);
|
||||
}
|
||||
}
|
||||
v.sort_by_key(|s| s.to_lowercase());
|
||||
let v = Arc::new(v);
|
||||
*cached = Some((key, v.clone()));
|
||||
v
|
||||
}
|
||||
|
||||
fn weight_name(w: f32) -> &'static str {
|
||||
@@ -699,22 +717,31 @@ fn weight_name(w: f32) -> &'static str {
|
||||
/// Style names ("Regular", "Bold Italic", …) available for a family.
|
||||
pub fn styles(family: &str) -> Vec<String> {
|
||||
let faces = photocraft_text::shared().lock().map(|mut e| e.fonts.faces(family)).unwrap_or_default();
|
||||
let mut v: Vec<(i32, bool, String)> = faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let w = weight_name(f.weight);
|
||||
let name = match (w, f.italic) {
|
||||
("Regular", true) => "Italic".to_string(),
|
||||
(w, true) => format!("{w} Italic"),
|
||||
(w, false) => w.to_string(),
|
||||
};
|
||||
(f.weight.round() as i32, f.italic, name)
|
||||
})
|
||||
.collect();
|
||||
let v = style_names(&faces);
|
||||
if v.is_empty() { vec![tl!("Regular").into()] } else { v }
|
||||
}
|
||||
|
||||
/// [`styles`] of these faces, lightest first. A variable face reports only its default instance
|
||||
/// (Montserrat's is Thin), yet the layout draws any weight its `wght` axis covers, so the standard
|
||||
/// weights in that range are listed too.
|
||||
fn style_names(faces: &[photocraft_text::FaceInfo]) -> Vec<String> {
|
||||
let label = |weight: f32, italic: bool| {
|
||||
let name = match (weight_name(weight), italic) {
|
||||
("Regular", true) => "Italic".to_string(),
|
||||
(w, true) => format!("{w} Italic"),
|
||||
(w, false) => w.to_string(),
|
||||
};
|
||||
(weight.round() as i32, italic, name)
|
||||
};
|
||||
let mut v: Vec<(i32, bool, String)> = faces.iter().map(|f| label(f.weight, f.italic)).collect();
|
||||
for f in faces {
|
||||
if let Some((_, min, _, max)) = f.axes.iter().find(|a| a.0 == "wght") {
|
||||
v.extend((100..=900).step_by(100).map(|w| w as f32).filter(|w| (*min..=*max).contains(w)).map(|w| label(w, f.italic)));
|
||||
}
|
||||
}
|
||||
v.sort();
|
||||
v.dedup_by(|a, b| a.2 == b.2);
|
||||
let v: Vec<String> = v.into_iter().map(|x| x.2).collect();
|
||||
if v.is_empty() { vec![tl!("Regular").into()] } else { v }
|
||||
v.into_iter().map(|x| x.2).collect()
|
||||
}
|
||||
|
||||
/// Localized style label for the dropdown. The raw string stays the engine's `fontStyle` key;
|
||||
@@ -726,7 +753,7 @@ pub fn style_label(style: &str) -> String {
|
||||
|
||||
/// Searchable font-family combo box.
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32) -> bool {
|
||||
font_picker_in(ui, current, width, families())
|
||||
font_picker_in(ui, current, width, &families())
|
||||
}
|
||||
|
||||
/// Maximum height of the font menu.
|
||||
@@ -768,6 +795,8 @@ fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families:
|
||||
for f in families.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)) {
|
||||
if ui.selectable_label(f == current, f).clicked() {
|
||||
*current = f.clone();
|
||||
// A served family not fetched yet: start now, before any text needs it.
|
||||
photocraft_text::served::request(f);
|
||||
changed = true;
|
||||
ui.data_mut(|d| d.remove::<String>(search_id));
|
||||
ui.close();
|
||||
|
||||
@@ -1124,3 +1124,28 @@ fn temporary_type_transform_large_document_preview() {
|
||||
);
|
||||
crate::type_transform::cancel_drag(&mut app);
|
||||
}
|
||||
|
||||
/// A variable face lists every standard weight of its `wght` axis, not only its default instance
|
||||
/// (Montserrat from Google Fonts: its default instance is Thin).
|
||||
#[test]
|
||||
fn variable_faces_list_the_weights_of_their_axis() {
|
||||
let face = |weight: f32, italic: bool, axes: Vec<(String, f32, f32, f32)>| photocraft_text::FaceInfo { family: "Montserrat".into(), weight, italic, axes };
|
||||
let full = || vec![("wght".to_string(), 100.0, 100.0, 900.0)];
|
||||
let names = super::style_names(&[face(100.0, false, full()), face(100.0, true, full())]);
|
||||
assert_eq!(names.len(), 18, "{names:?}");
|
||||
assert_eq!(names.first().map(String::as_str), Some("Thin"));
|
||||
for s in ["Regular", "Italic", "Bold", "Bold Italic", "Black Italic"] {
|
||||
assert!(names.iter().any(|n| n == s), "{s}: {names:?}");
|
||||
}
|
||||
// A narrower axis lists only its range; a static face only itself.
|
||||
let names = super::style_names(&[face(300.0, false, vec![("wght".into(), 300.0, 400.0, 700.0)])]);
|
||||
assert_eq!(names, ["Light", "Regular", "Medium", "SemiBold", "Bold"]);
|
||||
assert_eq!(super::style_names(&[face(700.0, false, Vec::new())]), ["Bold"]);
|
||||
}
|
||||
|
||||
/// Families the host serves are in the font menu before they are fetched.
|
||||
#[test]
|
||||
fn the_font_menu_lists_served_families() {
|
||||
photocraft_text::served::add_families(["Served Menu Test Serif".to_string()]);
|
||||
assert!(super::families().iter().any(|f| f == "Served Menu Test Serif"));
|
||||
}
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa
|
||||
- `crates/text/build.rs` reads `$CRAFT_FONTS_DIR/fonts/manifest.txt` and embeds the fonts as `photocraft_text::CRAFT_FONTS` (`crates/text/src/craft_fonts.rs`). Unset, `CRAFT_FONTS` is empty and the build is unchanged. A bad path is a build warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both).
|
||||
- **UI:** the Japanese fonts (BIZ UDPGothic Regular first) are the first Japanese fallback in the lazy CJK loader (`crates/ui-egui/src/cjk_fonts.rs`), ahead of the system Japanese fonts and in the same locale script order, appended last to every egui family with the usual baseline alignment.
|
||||
- **Type tool:** the text engine registers them in `FontDb::new` (so also with no system fonts) and puts them first in the Japanese slot of the locale-ordered fallback list: BIZ UDPGothic for sans runs, Shippori Mincho / BIZ UDMincho for serif runs.
|
||||
- **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. The web build therefore has no Japanese font yet (loading craft-fonts next to the wasm at run time would be the way to add one).
|
||||
- **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615).
|
||||
- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI's Linux job runs the tests a second time with `CRAFT_FONTS_DIR` set. Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-<family>.txt`.
|
||||
|
||||
## Graphics startup and device loss
|
||||
|
||||
@@ -9,6 +9,7 @@ static site in `photocraft-web-<version>/`:
|
||||
| `photocraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
|
||||
| `photocraft-web-<hash>_bg.wasm` | The app: about 19 MiB raw, 8 MiB with gzip, 5.6 MiB with Brotli (see [Sizes](#sizes)) |
|
||||
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
|
||||
| `fonts/` (optional) | Fonts loaded on demand, listed in `fonts/manifest.txt` (see [Fonts](#fonts)) |
|
||||
|
||||
There is no server-side code. Upload the folder's contents anywhere that serves static files.
|
||||
|
||||
@@ -125,6 +126,41 @@ location /photocraft/ {
|
||||
|
||||
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
|
||||
|
||||
## Fonts
|
||||
|
||||
The app has two fonts of its own, Inter and JetBrains Mono: a browser offers no system fonts, and
|
||||
embedding more would push the wasm over the size gate. A host can serve more fonts next to the
|
||||
app. They show in the font menus straight away, and a family's files are downloaded the first time
|
||||
it is picked or a document's text needs it, so starting the app costs one small text file.
|
||||
|
||||
1. Put the font files (`.ttf`, `.otf`, `.ttc`) anywhere under `fonts/` next to `index.html`.
|
||||
2. List them in `fonts/manifest.txt`, one file per line, in the format of
|
||||
[craft-fonts](https://github.com/storytold/craft-fonts)' own `fonts/manifest.txt`:
|
||||
|
||||
```text
|
||||
# family | style | file (relative to the site root) | scripts | licence | licence file | sha256 | source
|
||||
Open Sans | Regular | fonts/open-sans/OpenSans[wdth,wght].ttf
|
||||
Open Sans | Italic | fonts/open-sans/OpenSans-Italic[wdth,wght].ttf
|
||||
Lobster | Regular | fonts/lobster/Lobster-Regular.ttf | Latn,Cyrl | OFL-1.1 | fonts/lobster/OFL.txt
|
||||
```
|
||||
|
||||
The app reads the first three fields; the others are optional here. `family` must be the font's
|
||||
own family name (the typographic family in its `name` table, else the family), which is what the
|
||||
font menu shows once the file is in. The path is relative to the site root and can't leave it
|
||||
(no `..`, no `/` at the start, no other host). A variable font is one line: the style menu offers
|
||||
the weights of its `wght` axis.
|
||||
3. Put each font's licence next to it; the SIL Open Font License, for one, requires that.
|
||||
|
||||
A craft-fonts checkout is already laid out this way, and so is any folder with a
|
||||
`fonts/manifest.txt` and the files it lists: `PHOTOCRAFT_WEB_FONTS_DIR=<that folder>
|
||||
packaging/web/package.sh` copies the manifest, the fonts and their licences into the zip, keeping
|
||||
their paths. Without `fonts/manifest.txt` (a 404) nothing changes.
|
||||
|
||||
Serving: let `fonts/manifest.txt` revalidate (`Cache-Control: no-cache`) so edits show up; cache
|
||||
the font files for long only if their names change with their contents. Serve `.ttf` as `font/ttf`
|
||||
and `.otf` as `font/otf`, and compress them (Brotli roughly halves a TrueType file). The app
|
||||
fetches them with `fetch()`, so a Content Security Policy needs them allowed by `connect-src`.
|
||||
|
||||
## Embedding in a page (iframe)
|
||||
|
||||
```html
|
||||
|
||||
@@ -50,6 +50,24 @@ cp -R "$SITE/." "$WORK/$NAME/"
|
||||
cp "$HERE/_headers" "$HERE/.htaccess" "$WORK/$NAME/"
|
||||
cp "$HERE/README.md" "$WORK/$NAME/HOSTING.md"
|
||||
copy_docs "$WORK/$NAME"
|
||||
# Optional served fonts (README.md › Fonts): PHOTOCRAFT_WEB_FONTS_DIR=<a craft-fonts checkout, or any
|
||||
# folder with a fonts/manifest.txt in its format> copies the manifest, every font and licence file
|
||||
# it lists into the site, at the same paths. The wasm is unchanged; the app fetches fonts on demand.
|
||||
if [ -n "${PHOTOCRAFT_WEB_FONTS_DIR:-}" ]; then
|
||||
FONTS_MANIFEST="$PHOTOCRAFT_WEB_FONTS_DIR/fonts/manifest.txt"
|
||||
[ -f "$FONTS_MANIFEST" ] || { echo "error: $FONTS_MANIFEST missing (PHOTOCRAFT_WEB_FONTS_DIR)" >&2; exit 1; }
|
||||
mkdir -p "$WORK/$NAME/fonts"
|
||||
cp "$FONTS_MANIFEST" "$WORK/$NAME/fonts/manifest.txt"
|
||||
# Field 3 is the font file, field 6 (if any) its licence file; both relative to the folder.
|
||||
awk -F' [|] ' '!/^[[:space:]]*(#|$)/ { print $3; if ($6 != "") print $6 }' "$FONTS_MANIFEST" | sort -u | while IFS= read -r f; do
|
||||
case "$f" in
|
||||
"" | /* | *..* | *\\* | *://*) echo "error: $FONTS_MANIFEST: '$f' is not a relative path inside the folder" >&2; exit 1 ;;
|
||||
esac
|
||||
mkdir -p "$WORK/$NAME/$(dirname "$f")"
|
||||
cp "$PHOTOCRAFT_WEB_FONTS_DIR/$f" "$WORK/$NAME/$f"
|
||||
done
|
||||
echo "served fonts: $(grep -cvE '^[[:space:]]*(#|$)' "$FONTS_MANIFEST") manifest lines from $PHOTOCRAFT_WEB_FONTS_DIR"
|
||||
fi
|
||||
rm -f "$DIST/$NAME.zip"
|
||||
(cd "$WORK" && zip -qr9 "$DIST/$NAME.zip" "$NAME")
|
||||
echo "wrote $DIST/$NAME.zip"
|
||||
|
||||
Reference in New Issue
Block a user