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:
Anton
2026-10-09 20:14:47 +03:00
committed by GitHub
parent 54ff207004
commit 66b022cddd
18 changed files with 565 additions and 25 deletions
+1 -1
View File
@@ -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"] }
+97 -1
View File
@@ -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);
}
+42
View File
@@ -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() {
+26
View File
@@ -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();
+2 -2
View File
@@ -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;
+12
View File
@@ -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
}
+4
View File
@@ -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
+1
View File
@@ -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;
+167
View File
@@ -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));
}
}
+8
View File
@@ -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();
+1
View File
@@ -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;
+74
View File
@@ -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()]));
}
}
+1
View File
@@ -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));
}
+49 -20
View File
@@ -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();
+25
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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
+18
View File
@@ -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"