diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 9dc8fe0b9..69588c6d1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -11,6 +11,7 @@ on: - Cargo.lock - "**/Cargo.toml" - .github/workflows/freebsd.yml + - "**/build.rs" workflow_dispatch: concurrency: @@ -27,6 +28,14 @@ jobs: timeout-minutes: 300 steps: - uses: actions/checkout@v4 + # The optional craft-fonts build input (craftrules standards/fonts.md), so the Japanese font + # tests run here instead of skipping. + - name: Check out craft-fonts (pinned) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 with: @@ -45,4 +54,4 @@ jobs: git config --global --add safe.directory "*" rustc --version CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9de5013a..828d38a9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,11 @@ env: RUST_BACKTRACE: 1 EFFECTCRAFT_BUILD_SHA: ${{ github.sha }} INPUT_VERSION: ${{ inputs.version }} + # Official builds embed the shared fonts from storytold/craft-fonts (checked out at a pinned + # commit in every build job; see craftrules standards/fonts.md). REQUIRED makes a missing or + # broken checkout a build error instead of a silent build without the fonts. + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" jobs: version: @@ -70,6 +75,12 @@ jobs: EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + - name: Check out craft-fonts (pinned) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin @@ -120,6 +131,12 @@ jobs: EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + - name: Check out craft-fonts (pinned) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -170,6 +187,12 @@ jobs: NFPM_VERSION: 2.47.0 steps: - uses: actions/checkout@v4 + - name: Check out craft-fonts (pinned) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - name: Install build and packaging tools run: | sudo apt-get update @@ -215,6 +238,12 @@ jobs: WASM_BINDGEN_VERSION: 0.2.129 steps: - uses: actions/checkout@v4 + - name: Check out craft-fonts (pinned) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown diff --git a/.gitignore b/.gitignore index 81d7e8b75..f84e2113a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ plan/ /log .DS_Store .claude/worktrees/ +# The optional craft-fonts build input, checked out here by CI (AGENTS.md, "Fonts") +/craft-fonts/ diff --git a/AGENTS.md b/AGENTS.md index 62be3740e..1a2bb2ac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,26 @@ reproduce an image (for example, point lists traced from someone else's icon). non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub and other logos stay out; draw a generic icon instead). +### Fonts live in craft-fonts + +Font assets shared by the Crafting Apps live in +[`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never in this repo: **don't commit +font files here** (the small Latin UI fonts already in `assets/fonts/`, Inter, Noto Serif and +JetBrains Mono, stay). A new font goes into craft-fonts (file, licence, manifest line, attribution). +EffectCraft uses it as an **optional build input**, never a Cargo dependency: + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR=../craft-fonts cargo run -p effectcraft +``` + +`crates/text/build.rs` embeds the fonts in its `fonts/manifest.txt` as +`effectcraft_text::fonts::CRAFT_FONTS` (wasm32: only BIZ UDPGothic Regular, for the download size); +unset, `CRAFT_FONTS` is empty and the app builds, tests and runs as before. The Japanese faces are the +UI's last fallback in every egui family and the text engine's Japanese fallback before system fonts. +Tests that need them skip when `CRAFT_FONTS` is empty. Release builds set `CRAFT_FONTS_DIR` and +`CRAFT_FONTS_REQUIRED=1`. Standard: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). + ## 2. Clean-room code - Never read, disassemble or copy anything inside Adobe application bundles; file names and listings only. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f203522e3..b16f8c9b2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -70,6 +70,16 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall (sidecars `.attribution`). - Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly). +## Fonts from craft-fonts (optional build input, not files in this repo) + +Builds made with `CRAFT_FONTS_DIR` (all official releases; see [AGENTS.md](AGENTS.md)) embed the +Japanese fonts of [storytold/craft-fonts](https://github.com/storytold/craft-fonts): BIZ UDPGothic +(Regular, Bold), Shippori Mincho and BIZ UDMincho (Regular) on native platforms, BIZ UDPGothic Regular +only on the web. They are not in this repository. Each is under the SIL Open Font License 1.1 +(OFL-1.1); authors, sources and pinned upstream commits are in +[craft-fonts' ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md), and +release packages carry their licences as `OFL-.txt`. + ## Trained models (optional downloads, not bundled) The repository and the installers contain no model weights. These models are downloaded only when diff --git a/CLAUDE.md b/CLAUDE.md index afa08d3d8..2ecce002a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e ## Non-negotiables **Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`). - **Never crash** (AGENTS.md "Never crash"; outranks feature work: a crash loses people's work). Non-test code has no `unwrap()`/`expect()`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`: return `Result` and use `?`, `ok_or`, `let … else`. Input-derived indices, slices and arithmetic use `get()` and checked/saturating math; recursion is depth-limited; locks handle poisoning. The app's last-resort guard turns an escaped panic into an error and keeps the document. Every crash fix gets a regression test. Standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). +- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts)**; never commit font files here. It is an optional build input: `CRAFT_FONTS_DIR= cargo build` embeds them (Japanese UI and text fallback); unset, nothing changes. Details in AGENTS.md "Fonts live in craft-fonts"; standard: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). - **Clean-room.** Behaviour and public docs only. No GPL/LGPL/AGPL code. ffmpeg only as an external test oracle. - **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`). - **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96beecf95..800b6dc4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,12 +13,23 @@ cargo test --workspace cargo xtask ci # what every commit must pass ``` +Optional: build with the shared fonts from [craft-fonts](https://github.com/storytold/craft-fonts) +(Japanese UI and text fallback; release builds always do), and run their tests: + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace +``` + ## Ground rules - **Clean room.** Work from After Effects' public documentation and its observable behaviour only. Never use Adobe artwork, presets or code, and never copy GPL/LGPL/AGPL code. - **Every asset is attributed.** Add a `.attribution` sidecar and an [ATTRIBUTION.md](ATTRIBUTION.md) row; `cargo xtask assets` checks it. +- **Fonts live in [craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font + files here; add a font there and use it through `CRAFT_FONTS_DIR` (see AGENTS.md and + [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). - **Layering.** Nothing below the interface layer depends on UI or OS crates; see [docs/architecture.md](docs/architecture.md). - **Everything is a command.** New features are engine commands with an id, label, menu path, diff --git a/NOTICE b/NOTICE index 65ce4c42c..403d32fb6 100644 --- a/NOTICE +++ b/NOTICE @@ -15,3 +15,7 @@ list (author, source, license) is in ATTRIBUTION.md. Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt. JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. + +Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in +https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md, under the SIL Open Font License 1.1 +(their licence texts ship with the packages as OFL-.txt). diff --git a/README.md b/README.md index 51f6812ef..cf5c3a30d 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,11 @@ cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md) ``` +Japanese text in the UI and in text layers uses the system's Japanese fonts. To embed the shared +fonts from [craft-fonts](https://github.com/storytold/craft-fonts) instead (as release builds do, +and the only way the browser build gets Japanese), clone it and build with +`CRAFT_FONTS_DIR=../craft-fonts`; see [AGENTS.md](AGENTS.md). + To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints, tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING.md](CONTRIBUTING.md), [docs/architecture.md](docs/architecture.md) and [docs/testing.md](docs/testing.md). diff --git a/crates/text/build.rs b/crates/text/build.rs new file mode 100644 index 000000000..fb7119769 --- /dev/null +++ b/crates/text/build.rs @@ -0,0 +1,62 @@ +//! Optional build input: with `CRAFT_FONTS_DIR=`, embed the fonts listed in +//! its `fonts/manifest.txt` as `fonts::CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Recipe from +//! craft-fonts' `docs/integration.md` (see craftrules `standards/fonts.md`). Reads a local +//! directory only; never fetches anything. +//! +//! On wasm32 only the UI face (BIZ UDPGothic Regular, ~4.5 MB) is embedded: all four faces are +//! ~24 MB, too much for the web build's download (and Cloudflare's 25 MiB per-file limit). + +use std::fmt::Write as _; +use std::path::PathBuf; + +fn main() { + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + match craft_fonts(&dir) { + Ok(entries) => src.push_str(&entries), + Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { + println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); + } + Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + src.push_str("];\n"); + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + if let Err(e) = std::fs::write(&out, src) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// The one face embedded in wasm32 builds. +const WASM_FAMILY: &str = "BIZ UDPGothic"; +const WASM_STYLE: &str = "Regular"; + +/// One `CraftFont { .. }` initialiser per manifest line. +fn craft_fonts(dir: &std::path::Path) -> Result { + let manifest = dir.join("fonts/manifest.txt"); + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = String::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(); + let [family, style, file, scripts, ..] = f.as_slice() else { + return Err(format!("malformed manifest line: {line}")); + }; + if wasm && !(*family == WASM_FAMILY && *style == WASM_STYLE) { + continue; + } + let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; + println!("cargo::rerun-if-changed={}", path.display()); + let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); + let _ = writeln!( + out, + " CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},", + scripts.join(", "), + path.display().to_string(), + ); + } + Ok(out) +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 6e7bc1cbe..1a54990ff 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -6,8 +6,12 @@ //! bold/italic needed when the family lacks that style; a full name (`Yu Gothic Bold`) or //! PostScript name (`YuGothic-Bold`, how After Effects scripts name fonts) is accepted too. //! Unknown families fall back to Inter. [`fallback_for`] finds a face for a character the chosen -//! face lacks: bundled faces first, then the system's usual family for the character's script -//! (CJK), then any installed face that covers it. +//! face lacks: bundled faces first, then (for Japanese) the craft-fonts faces, then the system's +//! usual family for the character's script (CJK), then any installed face that covers it. +//! +//! [`CRAFT_FONTS`] are the fonts from the optional craft-fonts build input (`CRAFT_FONTS_DIR`, see +//! `crates/text/build.rs`); the `Jpan` ones are registered after the bundled faces (origin +//! `"craft-fonts"`). Empty in a build without it, where Japanese falls back to system fonts only. use std::path::PathBuf; use std::sync::{Arc, OnceLock, RwLock}; @@ -26,6 +30,30 @@ pub static INTER_ITALIC: &[u8] = include_bytes!("../../../assets/fonts/Inter-Ita pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); pub static NOTO_SERIF_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/NotoSerif-Regular.ttf"); +/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the font is for, e.g. `"Jpan"`. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); + +/// The craft-fonts faces for Japanese (`Jpan`), BIZ UDPGothic (the UI face) first, then the +/// Mincho families, each in manifest order. Empty in a build without craft-fonts. +pub fn craft_fonts_jpan() -> Vec<&'static CraftFont> { + let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan")).collect(); + v.sort_by_key(|f| !f.family.eq_ignore_ascii_case(CRAFT_UI_FAMILY)); + v +} + +/// The craft-fonts family preferred for UI (and sans-serif) Japanese text. +pub const CRAFT_UI_FAMILY: &str = "BIZ UDPGothic"; +/// The craft-fonts families preferred for serif (Mincho) Japanese text, in order. +const CRAFT_SERIF_FAMILIES: &[&str] = &["Shippori Mincho", "BIZ UDMincho"]; + /// The default family for new text. pub const DEFAULT_FAMILY: &str = "Inter"; @@ -272,6 +300,11 @@ fn db() -> &'static RwLock { push(&mut db, info_from(n, FaceData::Static(b), "bundled")); } } + for cf in craft_fonts_jpan() { + for n in read_faces_bytes(cf.bytes) { + push(&mut db, info_from(n, FaceData::Static(cf.bytes), "craft-fonts")); + } + } RwLock::new(db) }) } @@ -597,8 +630,8 @@ fn fallback_cache() -> &'static RwLock FaceId { @@ -613,6 +646,11 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { return f.id; } } + // Japanese: the craft-fonts faces (when built with them) before any system font, so the text + // looks the same on every platform and the web build has Japanese at all. + if let Some(id) = craft_fallback(c, &p.info) { + return id; + } for f in faces.iter().filter(|f| f.info.origin != "bundled" && f.bytes.get().is_some()) { if f.has_char(c) { return f.id; @@ -634,6 +672,45 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { found.unwrap_or(prefer) } +/// Whether a face is a serif design (Mincho / Ming / Song for CJK), from its family name. +fn is_serif(family: &str) -> bool { + let f = family.to_ascii_lowercase(); + (f.contains("serif") && !f.contains("sans")) || ["mincho", "ming", "song", "times", "georgia", "garamond"].iter().any(|k| f.contains(k)) +} + +/// A craft-fonts face for a Japanese character: Mincho families for serif text, BIZ UDPGothic +/// otherwise, in the preferred face's style. Han ideographs only when the locale puts Japanese +/// first (a Chinese or Korean locale keeps its own system families). `None` without craft-fonts. +fn craft_fallback(c: char, prefer: &FaceInfo) -> Option { + match script_of(c) { + FallbackScript::Japanese => {} + FallbackScript::Han if han_order()[0] == FallbackScript::Japanese => {} + _ => return None, + } + let faces = all_faces(); + let craft: Vec<&Arc> = faces.iter().filter(|f| f.info.origin == "craft-fonts").collect(); + if craft.is_empty() { + return None; + } + let mut order: Vec<&str> = if is_serif(&prefer.family) { CRAFT_SERIF_FAMILIES.to_vec() } else { vec![CRAFT_UI_FAMILY] }; + for f in std::iter::once(CRAFT_UI_FAMILY).chain(CRAFT_SERIF_FAMILIES.iter().copied()) { + if !order.contains(&f) { + order.push(f); + } + } + let (w, it) = style_wants(&prefer.style); + for fam in order { + let best = craft + .iter() + .filter(|f| f.info.family.eq_ignore_ascii_case(fam) && f.has_char(c)) + .min_by_key(|f| (if f.info.italic == it { 0 } else { 1000 }) + (f.info.weight as i32 - w as i32).unsigned_abs()); + if let Some(f) = best { + return Some(f.id); + } + } + craft.iter().find(|f| f.has_char(c)).map(|f| f.id) +} + /// The script-aware then exhaustive search behind [`fallback_for`]. fn system_fallback(c: char, style: &str) -> Option { let script = script_of(c); @@ -771,6 +848,51 @@ mod tests { assert_eq!(fallback_for('水', inter), f.id); } + /// With craft-fonts (`CRAFT_FONTS_DIR`), Japanese text falls back to its faces before any + /// system font: BIZ UDPGothic for sans text, a Mincho for serif text, with real glyphs. + #[test] + fn craft_fonts_render_japanese() { + if craft_fonts_jpan().is_empty() { + eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset); skipping"); + return; + } + let jp_first = han_order()[0] == FallbackScript::Japanese; + let inter = resolve("Inter", "Regular").face; + let serif = resolve("Noto Serif", "Regular").face; + for c in "日本語の文字".chars().filter(|c| jp_first || script_of(*c) == FallbackScript::Japanese) { + let sans = face(fallback_for(c, inter)); + assert_eq!(sans.info.origin, "craft-fonts", "{c} → {}", sans.info.family); + assert!(sans.info.family.eq_ignore_ascii_case(CRAFT_UI_FAMILY), "{c} → {}", sans.info.family); + assert!(sans.has_char(c)); + let m = face(fallback_for(c, serif)); + assert!(CRAFT_SERIF_FAMILIES.iter().any(|f| m.info.family.eq_ignore_ascii_case(f)), "{c} → {}", m.info.family); + } + // The bold cut for bold text. + let bold = face(fallback_for('あ', resolve("Inter", "Bold").face)); + assert!(bold.info.family.eq_ignore_ascii_case(CRAFT_UI_FAMILY) && bold.info.weight >= 600, "{} {}", bold.info.family, bold.info.style); + // Layout: no missing glyphs (glyph id 0) in the shaped text. + let l = crate::layout_text("日本語の文字", &crate::TextStyle { family: "Inter".into(), size: 30.0, ..Default::default() }, &Default::default()); + assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{:?}", l.glyphs.iter().map(|g| g.id).collect::>()); + // The families can also be chosen directly. + assert!(!resolve(CRAFT_UI_FAMILY, "Bold").missing); + } + + /// Without craft-fonts nothing changes: no extra faces, and Japanese goes straight to the + /// system search. + #[test] + fn works_without_craft_fonts() { + let craft = all_faces().iter().filter(|f| f.info.origin == "craft-fonts").count(); + let n: usize = craft_fonts_jpan().iter().map(|f| read_faces_bytes(f.bytes).len()).sum(); + assert_eq!(craft, n); + let inter = face(resolve("Inter", "Regular").face); + if CRAFT_FONTS.is_empty() { + assert_eq!(craft, 0); + assert_eq!(craft_fallback('あ', &inter.info), None); + } + assert_eq!(craft_fallback('A', &inter.info), None, "Latin never uses craft-fonts"); + assert_eq!(fallback_for('A', inter.id), inter.id); + } + #[test] fn metrics_and_coverage() { let f = face(resolve("Inter", "Regular").face); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0fcdb7eb4..9ff3104d6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -266,12 +266,33 @@ fn default_labels() -> [Color32; 17] { }) } +/// The Japanese fonts from the optional craft-fonts build input (`CRAFT_FONTS_DIR`, see +/// `effectcraft_text::fonts::CRAFT_FONTS`), appended to every family after the app's own fonts and +/// egui's defaults, BIZ UDPGothic first (its Bold first in the semibold family). Nothing is added +/// in a build without craft-fonts. The web build has no system fonts, so this is how it gets +/// Japanese. +fn add_craft_fonts(fonts: &mut FontDefinitions) { + let craft = effectcraft_text::fonts::craft_fonts_jpan(); + let key = |f: &effectcraft_text::fonts::CraftFont| format!("craft-{}-{}", f.family, f.style).to_ascii_lowercase().replace(' ', "-"); + for f in craft.iter().copied() { + fonts.font_data.insert(key(f), Arc::new(FontData::from_static(f.bytes))); + } + for (family, list) in fonts.families.iter_mut() { + let mut ordered = craft.clone(); + if *family == FontFamily::Name("semibold".into()) { + ordered.sort_by_key(|f| !(f.family.eq_ignore_ascii_case(effectcraft_text::fonts::CRAFT_UI_FAMILY) && f.style.eq_ignore_ascii_case("Bold"))); + } + list.extend(ordered.into_iter().map(key)); + } +} + static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf"); static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); static JETBRAINS_MONO: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); -/// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono) and egui visuals. +/// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono, plus craft-fonts' Japanese faces +/// when built with them) and egui visuals. pub fn install(ctx: &egui::Context, t: &Tokens) { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(INTER_REGULAR))); @@ -282,13 +303,17 @@ pub fn install(ctx: &egui::Context, t: &Tokens) { fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]); - // Reuse the text engine's script-aware system fallback (#84), without embedding a CJK font. + // craft-fonts' Japanese faces first, when built with them (they are also what the text engine + // falls back to, so nothing more to add); otherwise reuse the text engine's script-aware + // system fallback (#84), without embedding a CJK font. + add_craft_fonts(&mut fonts); #[cfg(not(target_arch = "wasm32"))] { use effectcraft_text::fonts; let base = fonts::resolve("Inter", "Regular").face; let face = fonts::face(fonts::fallback_for('あ', base)); if face.has_char('あ') + && face.info.origin != "craft-fonts" && let Some(font) = face.font() { // Use the already-read, parsed bytes rather than reading a font file twice. diff --git a/crates/ui-egui/tests/ui_craft_fonts.rs b/crates/ui-egui/tests/ui_craft_fonts.rs new file mode 100644 index 000000000..37c1f416a --- /dev/null +++ b/crates/ui-egui/tests/ui_craft_fonts.rs @@ -0,0 +1,79 @@ +//! UI fonts and the optional craft-fonts build input (`CRAFT_FONTS_DIR`): with it, Japanese renders +//! with real glyphs in every UI font family (also on the web, which has no system fonts); without +//! it, the UI installs and renders exactly as before. + +use effectcraft_ui_egui::theme::{self, ThemeKind, Tokens}; + +const FAMILIES: [&str; 4] = ["proportional", "monospace", "medium", "semibold"]; + +fn family(name: &str) -> egui::FontFamily { + match name { + "proportional" => egui::FontFamily::Proportional, + "monospace" => egui::FontFamily::Monospace, + n => egui::FontFamily::Name(n.into()), + } +} + +fn installed() -> egui::Context { + let ctx = egui::Context::default(); + theme::install(&ctx, &Tokens::for_kind(ThemeKind::Dark)); + // Fonts are loaded at the start of the first frame. + let _ = ctx.run_ui(egui::RawInput::default(), |_| {}); + ctx +} + +/// Where a glyph sits in the font atlas. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Uv { + min: [u16; 2], + max: [u16; 2], + size: egui::Vec2, +} + +/// The atlas rectangle of each character of `text`, and of the replacement glyph. +fn uvs(ctx: &egui::Context, text: &str, fam: &str) -> (Vec<(char, Uv)>, Uv) { + let font = egui::FontId::new(13.0, family(fam)); + ctx.fonts_mut(|fonts| { + // A non-character first: always the replacement glyph. + let mut out: Vec<(char, Uv)> = Vec::new(); + for c in std::iter::once('\u{10ffff}').chain(text.chars()) { + let g = fonts.layout_no_wrap(c.to_string(), font.clone(), egui::Color32::WHITE); + let uv = g.rows.first().and_then(|r| r.glyphs.first()).map(|g| Uv { min: g.uv_rect.min, max: g.uv_rect.max, size: g.uv_rect.size }); + out.push((c, uv.unwrap_or(Uv { min: [0, 0], max: [0, 0], size: egui::Vec2::ZERO }))); + } + let missing = out.remove(0).1; + (out, missing) + }) +} + +#[test] +fn craft_fonts_render_japanese_in_every_ui_family() { + if effectcraft_text::fonts::craft_fonts_jpan().is_empty() { + eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset); skipping"); + return; + } + let ctx = installed(); + for fam in FAMILIES { + let (glyphs, missing) = uvs(&ctx, "日本語の文字", fam); + for (c, uv) in glyphs { + assert!(uv.size.x > 0.0 && uv.size.y > 0.0, "empty {c} in {fam}"); + assert_ne!(uv, missing, "replacement glyph (tofu) for {c} in {fam}"); + } + } +} + +#[test] +fn ui_fonts_work_without_craft_fonts() { + // Runs with or without craft-fonts: the app's own fonts render Latin text in every family. + let ctx = installed(); + for fam in FAMILIES { + let (glyphs, missing) = uvs(&ctx, "EffectCraft", fam); + for (c, uv) in glyphs { + assert!(uv.size.x > 0.0 && uv.size.y > 0.0, "empty {c} in {fam}"); + assert_ne!(uv, missing, "replacement glyph for {c} in {fam}"); + } + } + if effectcraft_text::fonts::CRAFT_FONTS.is_empty() { + assert!(effectcraft_text::fonts::craft_fonts_jpan().is_empty()); + } +} diff --git a/docs/web.md b/docs/web.md index 623b6dd9f..2f2479bc9 100644 --- a/docs/web.md +++ b/docs/web.md @@ -62,7 +62,7 @@ device of their own), `?nosw` (don't register the service worker). | export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` | | rfd file dialogs | `` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name | | Media Browser on the file system | browser storage and folders opened with the File System Access API (below) | -| system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there | +| system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there, plus BIZ UDPGothic Regular for Japanese (UI and text) when built with craft-fonts (`CRAFT_FONTS_DIR`, AGENTS.md; release builds do; +4.5 MB of `.wasm`) | | TCP control channel / MCP | `window.effectcraft` (below) | | cpal audio output | Web Audio (below) | | GPU compositor on the desktop's wgpu device | the same compositor on eframe's WebGPU device, and GPU effects on each frame worker's own WebGPU device (below) | diff --git a/packaging/env.sh b/packaging/env.sh index 6a1bfa286..814a8dac4 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -50,6 +50,19 @@ copy_docs() { for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi done + copy_craft_font_licences "$dest" +} + +# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by release.yml) embed its OFL-1.1 fonts, whose +# licence must travel with them: copy fonts//OFL.txt as OFL-.txt. No-op otherwise. +copy_craft_font_licences() { + local dest="$1" ofl fam + [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + for ofl in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do + [ -f "$ofl" ] || continue + fam="$(basename "$(dirname "$ofl")")" + cp "$ofl" "$dest/OFL-$fam.txt" + done } # Portable SHA-256 of a file (prints just the hash). diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 0dbf58046..9ccf8edcf 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,7 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/effectcraft" "$APP/Contents/MacOS/EffectCraft" cp "$ROOT/assets/app-icon/effectcraft.icns" "$APP/Contents/Resources/EffectCraft.icns" +copy_craft_font_licences "$APP/Contents/Resources" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ -e "s/@BUILD_SHA@/${EFFECTCRAFT_BUILD_SHA:-unknown}/g" \ "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 18551225c..47b28a617 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -110,6 +110,12 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } +# Builds made with craft-fonts embed its OFL-1.1 fonts; their licences travel with them. +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -ErrorAction SilentlyContinue) { + Copy-Item $ofl.FullName (Join-Path $Portable "OFL-$($ofl.Directory.Name).txt") + } +} $Zip = Join-Path $Dist "effectcraft-$Version-windows-$Arch-portable.zip" Remove-Item -Force $Zip -ErrorAction SilentlyContinue Compress-Archive -Path $Portable -DestinationPath $Zip