From 9953befe2887cca9b94fd46088f9e8270c9cc295 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 11:40:10 -0400 Subject: [PATCH] Fonts: use craft-fonts as an optional build input crates/text/build.rs embeds the fonts of a storytold/craft-fonts checkout named by CRAFT_FONTS_DIR as filmcraft_text::fonts::CRAFT_FONTS (native: all of them; wasm32: only BIZ UDPGothic Regular). Unset, CRAFT_FONTS is empty and nothing changes. A bad CRAFT_FONTS_DIR is a warning, or an error with CRAFT_FONTS_REQUIRED=1. Never a Cargo dependency, no network. - UI: the Japanese faces are the last fallback of every egui font family (BIZ UDPGothic first, Bold first in the semibold/medium families). - Text engine: registered after the bundled faces; fallback_for picks them after the bundled faces and before system faces (Mincho for serif text, Gothic otherwise, nearest weight). - Tests: Japanese glyphs (no tofu) in the UI and title paths, skipped without craft-fonts; the theme works with CRAFT_FONTS empty. - Release: every build job checks out craft-fonts at a pinned commit and builds with CRAFT_FONTS_DIR + CRAFT_FONTS_REQUIRED; packages carry OFL-.txt. NOTICE, ATTRIBUTION.md (optional build input), AGENTS.md, CLAUDE.md, README and docs/contributing.md describe it. FilmCraft main bundles no Japanese font, so no font files move. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 34 +++++++++++++ AGENTS.md | 7 +++ ATTRIBUTION.md | 15 ++++++ CLAUDE.md | 1 + NOTICE | 3 ++ README.md | 11 ++++- crates/text/README.md | 2 +- crates/text/build.rs | 63 ++++++++++++++++++++++++ crates/text/src/fonts.rs | 83 +++++++++++++++++++++++++++++-- crates/text/src/lib.rs | 3 +- crates/ui-egui/src/theme.rs | 92 +++++++++++++++++++++++++++++++++-- docs/contributing.md | 28 +++++++++++ packaging/env.sh | 16 +++++- packaging/macos/package.sh | 2 + packaging/windows/package.ps1 | 6 +++ 15 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 crates/text/build.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc1077e4..526eb251 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,12 @@ jobs: FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin @@ -90,6 +96,8 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/macos/package.sh --arch universal - uses: actions/upload-artifact@v4 with: @@ -120,6 +128,12 @@ jobs: FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -144,6 +158,8 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - uses: actions/upload-artifact@v4 with: @@ -170,6 +186,12 @@ jobs: NFPM_VERSION: 2.47.0 steps: - uses: actions/checkout@v4 + # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - name: Install build and packaging tools run: | sudo apt-get update @@ -187,6 +209,9 @@ jobs: if: inputs.version != '' run: cargo xtask version set "$FILMCRAFT_VERSION" - name: Build and package (AppImage, deb, rpm, tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/linux/package.sh - name: Check the packages run: | @@ -215,6 +240,12 @@ jobs: WASM_BINDGEN_VERSION: 0.2.129 steps: - uses: actions/checkout@v4 + # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown @@ -232,6 +263,9 @@ jobs: if: inputs.version != '' run: cargo xtask version set "$FILMCRAFT_VERSION" - name: Build and zip + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - uses: actions/upload-artifact@v4 with: diff --git a/AGENTS.md b/AGENTS.md index 26564d30..2138902e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,13 @@ reproduce an image (for example, point lists traced from someone else's icon). need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other 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). +9. **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this + repo.** Don't commit new font files (the small Latin UI fonts already in `assets/fonts/` stay). A + font FilmCraft needs is added to craft-fonts, which the app reads through the optional build input + `CRAFT_FONTS_DIR=` (`crates/text/build.rs` embeds the fonts in its + `fonts/manifest.txt` as `filmcraft_text::fonts::CRAFT_FONTS`; unset, it is empty and the app uses its + bundled and system fonts). Never add craft-fonts to a `Cargo.toml`. Code using `CRAFT_FONTS` must work + when it is empty. Standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). ## 2. Clean-room code diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 17c60b0d..e3bf7cde 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -106,6 +106,21 @@ is traced or derived from Adobe artwork. | Lumetri Presets (Effects panel: Cinematic, Film Emulation, Monochrome, Technical) and their preview picture | `crates/render/src/lumetri_presets.rs`: Lumetri parameter values chosen by contributors, descriptive names, and a procedural preview picture (dusk sky, ridges, lake, grey ramp, colour chips) | | Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork | +## Optional build input: craft-fonts + +These fonts are **not files in this repository**. Builds made with the optional build input +`CRAFT_FONTS_DIR` (all official releases; see [docs/contributing.md](docs/contributing.md#building-with-craft-fonts)) +embed them from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), where each file, +its author, pinned upstream source and licence text are listed in its +[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). Release packages +carry each font's licence as `OFL-.txt`. + +| Font | Use in FilmCraft | Licence | +|---|---|---| +| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 | +| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 | +| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 | + ## First-party brand marks The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and diff --git a/CLAUDE.md b/CLAUDE.md index 03cab747..b90a8b01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target ## Non-negotiables **Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** - **Never crash** ([`AGENTS.md`](AGENTS.md) §0, standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)). People depend on FilmCraft; a crash loses their work, so this outranks feature work. No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` or `unsafe` outside tests: return `Result` and propagate with `?` (`ok_or`, `let … else`, `if let`). Treat every number from media, project files, presets or CLI/MCP/control params as hostile: `get()` instead of `[i]`, checked/saturating math, no division by possibly-zero values, capped allocations, bounded recursion. `lock().unwrap_or_else(PoisonError::into_inner)`; background jobs run under `catch_unwind`. The panic hook + UI `catch_unwind` (`crates/ui-egui/src/crash.rs`) are the last-resort guard, not a licence. Every crash fix gets a regression test that panicked before. In particular (§1): never use Adobe iconography, images or any other Adobe asset. Every asset must be openly licensed (OSS / public domain / Creative Commons, or original work by a contributor) and must have a `.attribution` sidecar plus an entry in `ATTRIBUTION.md`. `cargo xtask assets` enforces this. +- **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo (no new font files here; [`AGENTS.md`](AGENTS.md) §1.9, standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Build with them: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo run -p filmcraft` (`crates/text/build.rs` embeds them as `filmcraft_text::fonts::CRAFT_FONTS`, used for Japanese in the UI and titles). Unset, `CRAFT_FONTS` is empty and everything must still build, test and run; never add craft-fonts to a `Cargo.toml`. Tests on its glyphs skip when it is empty, so run the gates with and without `CRAFT_FONTS_DIR` when you touch fonts. - **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, LUTs, fonts. Never copy GPL/LGPL/AGPL code (FFmpeg, x264, x265, MLT, Kdenlive, Shotcut, Olive, LAME…). ffmpeg/ffprobe run only as external test oracles / fixture generators — never linked or shipped. - **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback. - **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`. diff --git a/NOTICE b/NOTICE index 3bc53462..221a0acc 100644 --- a/NOTICE +++ b/NOTICE @@ -15,4 +15,7 @@ list (author, source, license) is in ATTRIBUTION.md. Inter font (assets/fonts/Inter-*.ttf) — OFL-1.1. See assets/fonts/OFL-Inter.txt. Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-NotoSerif.txt. JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-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 (BIZ UDPGothic, Shippori Mincho, +BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt. Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md. diff --git a/README.md b/README.md index 937dae84..fce779ee 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,13 @@ cargo run --release -p filmcraft-cli -- commands # list every engine c cargo run --release -p filmcraft-cli -- mcp # MCP server (headless) ``` +Japanese text in the interface and in titles comes from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (release builds always include it; without it FilmCraft uses its own and the system's fonts): + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p filmcraft +``` + The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft). ## Documentation @@ -351,7 +358,9 @@ FilmCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APAC Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors. Required notices are in [NOTICE](NOTICE). Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed -with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). +with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds made with +[craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) also embed its fonts +(OFL-1.1), listed in its [ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). **Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md). diff --git a/crates/text/README.md b/crates/text/README.md index 051495e7..6d2764ac 100644 --- a/crates/text/README.md +++ b/crates/text/README.md @@ -6,7 +6,7 @@ and the Timecode / Clip Name effects. Pure Rust; it builds for `wasm32-unknown-u | Module | What | |---|---| -| `fonts` | Font database. Bundled faces (always present, also on the web): Inter Regular / Medium / SemiBold / Bold / Italic, JetBrains Mono Regular, Noto Serif Regular — all SIL OFL 1.1, unmodified, in `assets/fonts/` with `.attribution` sidecars. More faces come from `FontSource`s: `DirectorySource::system()` scans the platform font folders on first need (only the `name` and `OS/2` tables are read, via `sfnt`); on wasm the scan finds nothing and hosts can call `add_font_data`. `resolve(family, style)` picks a face, synthesises bold/italic when the family lacks them, and falls back to Inter (flagged `missing`). `fallback_for` finds a face for characters the chosen face lacks. | +| `fonts` | Font database. Bundled faces (always present, also on the web): Inter Regular / Medium / SemiBold / Bold / Italic, JetBrains Mono Regular, Noto Serif Regular — all SIL OFL 1.1, unmodified, in `assets/fonts/` with `.attribution` sidecars. More faces come from `FontSource`s: `DirectorySource::system()` scans the platform font folders on first need (only the `name` and `OS/2` tables are read, via `sfnt`); on wasm the scan finds nothing and hosts can call `add_font_data`. `resolve(family, style)` picks a face, synthesises bold/italic when the family lacks them, and falls back to Inter (flagged `missing`). `fallback_for` finds a face for characters the chosen face lacks: bundled faces first, then the optional craft-fonts faces (`CRAFT_FONTS`, embedded by `build.rs` when built with `CRAFT_FONTS_DIR`; Mincho for serif text, Gothic otherwise, nearest weight), then loaded system faces. | | `layout` | Paragraph layout: per-character font fallback → bidi levels (UAX #9) → items (runs of one face, direction and size) shaped with harfrust (OpenType kerning, ligatures, marks, complex scripts) → tracking → line breaking at UAX #14 opportunities (area text) → visual reordering per line → alignment (left / centre / right / justify), leading (added to the natural line height), baseline shift, all caps / synthesised small caps (78 %), underline. Every character boundary gets a caret stop (ligatures are split evenly), so editors can place carets, hit-test and draw selections. Layouts are cached by text + style (512 entries, LRU). | | `layout_rich` | Per-character style runs (`StyleRun`: a byte range with its own `TextStyle`): runs split shaping items (font, size, tracking, caps, faux styles), baseline shift and underline apply per run, a line holding a larger run is pushed down from the line above (baseline distance = max(base line height, previous descent + this line's ascent + leading)), and every glyph / underline carries its style index so renderers can colour runs (`render::draw_run`). | | `raster` | Our anti-aliased rasteriser (non-zero winding, 5 sub-scanlines, exact horizontal coverage, active-edge list), contour paths with transforms, rounded rectangles, ellipses, polygons and outline emboldening. Coverage is linear, so compositing in linear light is correct. | diff --git a/crates/text/build.rs b/crates/text/build.rs new file mode 100644 index 00000000..682805d6 --- /dev/null +++ b/crates/text/build.rs @@ -0,0 +1,63 @@ +//! Optional craft-fonts build input (see storytold/craft-fonts `docs/integration.md` and +//! craftrules `standards/fonts.md`). +//! +//! With `CRAFT_FONTS_DIR=` this embeds every font listed in its +//! `fonts/manifest.txt` as [`CRAFT_FONTS`](crate::fonts::CRAFT_FONTS); unset, `CRAFT_FONTS` is +//! empty and nothing else changes. A `CRAFT_FONTS_DIR` that is not a checkout is a warning, or an +//! error with `CRAFT_FONTS_REQUIRED=1` (release builds). +//! +//! Native builds embed every font. The web (wasm32) build embeds only the UI font (BIZ UDPGothic +//! Regular, ~4.5 MB) to stay under the web size budget (Cloudflare serves files up to 25 MiB). +//! Only the local directory is read: no network. + +use std::fmt::Write as _; +use std::path::PathBuf; + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + 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()); + } +} + +/// 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").as_deref() == Ok("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 == "BIZ UDPGothic" && *style == "Regular") { + 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 d0ecb2c1..963f28e9 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1,5 +1,6 @@ -//! The font database: bundled fonts (always available, also on the web) plus fonts discovered by -//! [`FontSource`]s (system font folders on native platforms). +//! The font database: bundled fonts (always available, also on the web), the optional +//! [`CRAFT_FONTS`] (present when built with `CRAFT_FONTS_DIR`, also on the web) plus fonts +//! discovered by [`FontSource`]s (system font folders on native platforms). //! //! Faces are addressed by [`FaceId`] (an index that never changes for the life of the process). //! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic @@ -22,6 +23,27 @@ 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`; see +/// `build.rs` and storytold/craft-fonts `docs/integration.md`). +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 entries for Japanese (`Jpan`), in [`CRAFT_FONTS`] order. Empty when the app was +/// built without craft-fonts. +pub fn craft_japanese() -> impl Iterator { + CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan")) +} + +/// Origin of faces registered from [`CRAFT_FONTS`]. +pub const CRAFT_ORIGIN: &str = "craft-fonts"; + /// The default family for new text. pub const DEFAULT_FAMILY: &str = "Inter"; @@ -240,6 +262,13 @@ fn db() -> &'static RwLock { push(&mut db, info_from(n, FaceData::Static(b), "bundled")); } } + // after the bundled faces, before anything a FontSource finds (a system copy of the same + // family + style is then skipped as a duplicate) + for f in CRAFT_FONTS { + for n in read_faces_bytes(f.bytes) { + push(&mut db, info_from(n, FaceData::Static(f.bytes), CRAFT_ORIGIN)); + } + } RwLock::new(db) }) } @@ -394,7 +423,15 @@ fn resolve_in(family: &str, style: &str) -> Option { Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false }) } -/// A face that covers `c`, preferring `prefer`; bundled and already-registered faces are searched. +/// Whether a family name reads as a serif face (Noto Serif, Shippori Mincho, …), to pick a Mincho +/// fallback for serif text and a Gothic one otherwise. +fn is_serif(family: &str) -> bool { + let f = family.to_ascii_lowercase(); + (f.contains("serif") && !f.contains("sans")) || f.contains("mincho") +} + +/// A face that covers `c`, preferring `prefer`; bundled, craft-fonts and already-registered faces +/// are searched, in that order. pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { let p = face(prefer); if p.has_char(c) || c.is_control() { @@ -407,6 +444,15 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { return f.id; } } + // then the craft-fonts faces (Japanese): Mincho for serif text, Gothic otherwise, nearest weight + let serif = is_serif(&p.info.family); + let mut craft: Vec<&Arc> = faces.iter().filter(|f| f.info.origin == CRAFT_ORIGIN).collect(); + craft.sort_by_key(|f| (is_serif(&f.info.family) != serif, f.info.italic != p.info.italic, f.info.weight.abs_diff(p.info.weight))); + for f in craft { + if f.has_char(c) { + return f.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; @@ -464,6 +510,37 @@ mod tests { } } + /// craft-fonts faces are registered after the bundled ones and serve Japanese characters the + /// requested face lacks: Gothic for sans text, Mincho for serif text. + #[test] + fn japanese_falls_back_to_craft_fonts() { + let craft: Vec<_> = all_faces().into_iter().filter(|f| f.info.origin == CRAFT_ORIGIN).collect(); + if craft_japanese().next().is_none() { + eprintln!("SKIPPED: built without craft-fonts (set CRAFT_FONTS_DIR to run)"); + assert!(craft.is_empty()); + return; + } + assert!(craft.len() >= craft_japanese().count(), "{} faces", craft.len()); + let bundled = all_faces().iter().take_while(|f| f.info.origin == "bundled").count(); + assert!(craft.iter().all(|f| f.id >= bundled), "craft-fonts faces come after the bundled faces"); + let sans = resolve("Inter", "Regular").face; + let serif = resolve("Noto Serif", "Regular").face; + for c in "日本語の文字".chars() { + let g = face(fallback_for(c, sans)); + assert_eq!(g.info.origin, CRAFT_ORIGIN, "{c}"); + assert!(g.has_char(c) && !is_serif(&g.info.family), "{c}: {}", g.info.family); + let m = face(fallback_for(c, serif)); + assert!(m.has_char(c) && is_serif(&m.info.family), "{c}: {}", m.info.family); + } + // the bold UI face for bold text + let bold = face(fallback_for('日', resolve("Inter", "Bold").face)); + assert!(bold.info.weight >= 600, "{} {}", bold.info.family, bold.info.style); + // and laid out with real glyphs (no tofu) + let l = crate::layout("日本語の文字", &crate::TextStyle { size: 40.0, ..Default::default() }, &Default::default()); + assert_eq!(l.glyphs.len(), 6); + assert!(l.glyphs.iter().all(|g| g.id != 0), "{:?}", l.glyphs); + } + #[test] fn metrics_and_coverage() { let f = face(resolve("Inter", "Regular").face); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 62517076..c9414a7b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1,6 +1,7 @@ //! FilmCraft text engine (layer L1). //! -//! - [`fonts`]: font database — bundled OFL fonts (Inter, JetBrains Mono, Noto Serif) plus faces +//! - [`fonts`]: font database — bundled OFL fonts (Inter, JetBrains Mono, Noto Serif), the optional +//! craft-fonts ([`fonts::CRAFT_FONTS`], Japanese; empty unless built with `CRAFT_FONTS_DIR`) plus faces //! discovered by [`fonts::FontSource`]s (system font folders on native platforms; nothing on the //! web unless the host registers font data), family/style resolution with synthetic bold/italic //! and per-character fallback. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0a6f5f76..51ff869d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -230,13 +230,14 @@ impl Tokens { } } -/// Install fonts (Inter, Inter SemiBold, JetBrains Mono) and egui visuals. -/// Every font family the theme defines (fallback fonts such as the system Japanese font are added to -/// each of them). +/// Every font family the theme defines (fallback fonts such as the Japanese fonts are added to each +/// of them). pub fn font_families() -> Vec { vec![FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("semibold".into()), FontFamily::Name("medium".into())] } +/// Install fonts (Inter, Inter SemiBold, JetBrains Mono, then the Japanese craft-fonts when built with +/// `CRAFT_FONTS_DIR`) 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(filmcraft_text::fonts::INTER_REGULAR))); @@ -247,10 +248,32 @@ 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()]); + add_craft_fonts(&mut fonts); ctx.set_fonts(fonts); apply_visuals(ctx, t); } +/// Name of the egui font for a craft-fonts entry. +fn craft_font_name(f: &filmcraft_text::fonts::CraftFont) -> String { + format!("craft:{} {}", f.family, f.style) +} + +/// Append the Japanese craft-fonts (empty unless built with `CRAFT_FONTS_DIR`) as the last fallbacks +/// of every font family, after the app's own fonts: BIZ UDPGothic first (bold before regular in +/// the "semibold" and "medium" families), then the other Japanese faces. +fn add_craft_fonts(fonts: &mut FontDefinitions) { + let jpan: Vec<_> = filmcraft_text::fonts::craft_japanese().collect(); + for f in &jpan { + fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes))); + } + for (family, stack) in fonts.families.iter_mut() { + let heavy = matches!(family, FontFamily::Name(n) if matches!(n.as_ref(), "semibold" | "medium")); + let mut order = jpan.clone(); + order.sort_by_key(|f| (!f.family.contains("Gothic"), (f.style == "Bold") != heavy)); + stack.extend(order.iter().map(|f| craft_font_name(f))); + } +} + pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() }; v.panel_fill = t.panel_bg; @@ -300,3 +323,66 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { s.animation_time = 0.12; }); } + +#[cfg(test)] +mod tests { + use super::*; + + const JAPANESE: &str = "日本語の文字"; + + fn installed() -> egui::Context { + let ctx = egui::Context::default(); + install(&ctx, &Tokens::for_kind(ThemeKind::default())); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx + } + + /// Built with craft-fonts: every font family ends with the Japanese faces (BIZ UDPGothic + /// first), and Japanese text gets real glyphs, not the replacement box. + #[test] + fn japanese_renders_with_craft_fonts() { + if filmcraft_text::fonts::craft_japanese().next().is_none() { + eprintln!("SKIPPED: built without craft-fonts (set CRAFT_FONTS_DIR to run)"); + return; + } + let ctx = installed(); + ctx.fonts_mut(|fonts| { + let defs = fonts.definitions().clone(); + for (family, stack) in &defs.families { + let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len()); + assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); + assert!(stack[first].starts_with("craft:BIZ UDPGothic"), "{family:?}: {stack:?}"); + } + for family in [FontFamily::Proportional, FontFamily::Monospace] { + let font = FontId::new(13.0, family); + for ch in JAPANESE.chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}"); + } + } + }); + // laid out and drawn: the replacement box (U+FFFD) would show as one glyph per character + // from the fallback face; a real face gives each character its own width + let galley = ctx.fonts_mut(|f| f.layout_no_wrap(JAPANESE.into(), FontId::new(13.0, FontFamily::Proportional), Color32::WHITE)); + assert_eq!(galley.rows.iter().map(|r| r.glyphs.len()).sum::(), JAPANESE.chars().count()); + assert!(galley.size().x > 13.0 * 4.0, "{:?}", galley.size()); + } + + /// Built without craft-fonts: the theme installs only the app's own fonts and Latin text + /// works as before. + #[test] + fn works_without_craft_fonts() { + let ctx = installed(); + let n = filmcraft_text::fonts::craft_japanese().count(); + ctx.fonts_mut(|fonts| { + let defs = fonts.definitions().clone(); + assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n); + assert_eq!(defs.families[&FontFamily::Proportional].first().map(String::as_str), Some("inter")); + assert_eq!(defs.families[&FontFamily::Monospace].first().map(String::as_str), Some("jbmono")); + assert!(fonts.has_glyph(&FontId::new(13.0, FontFamily::Proportional), 'A')); + if n == 0 { + assert!(defs.families.values().flatten().all(|name| !name.starts_with("craft:"))); + } + }); + } +} diff --git a/docs/contributing.md b/docs/contributing.md index 9ec38dc1..87425ab1 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -45,6 +45,26 @@ code 2 instead of a window. Dev builds compile dependencies at `opt-level = 2` and workspace crates at `opt-level = 1`. For playback and codec speed, use `--release`. +### Building with craft-fonts + +Font assets live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this +repo ([standard](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). It is an optional build input, not a Cargo +dependency: + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p filmcraft +``` + +`crates/text/build.rs` reads its `fonts/manifest.txt` and embeds the fonts as +`filmcraft_text::fonts::CRAFT_FONTS` (native: all of them; the web build: only BIZ UDPGothic +Regular, to keep the wasm small). The Japanese ones become the last fallback of every UI font family +(`crates/ui-egui/src/theme.rs`, BIZ UDPGothic first) and fallback faces in the text engine (Gothic +for sans text, Mincho for serif text). Without `CRAFT_FONTS_DIR`, `CRAFT_FONTS` is empty and +FilmCraft uses its bundled and system fonts. A `CRAFT_FONTS_DIR` that is not a checkout is a build +warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). Tests on these fonts' +glyphs skip when it is empty; when you touch fonts, run the gates both with and without it. + ## 3. Quality gates Every commit must pass all of these: @@ -59,6 +79,8 @@ Every commit must pass all of these: | wasm | `cargo xtask wasm` (checks every L0–L4 crate, `filmcraft-ui-egui` and `filmcraft-web` for `wasm32-unknown-unknown`) | | All of the above | `cargo xtask ci` (runs clippy and tests with `--release`) | +Run them with `CRAFT_FONTS_DIR` set too when you change font code ([craft-fonts](#building-with-craft-fonts)). + `cargo xtask` is an alias in `.cargo/config.toml` for `cargo run -p xtask --`. ### Never crash @@ -209,6 +231,12 @@ In short: - Screenshots show only FilmCraft (or other open projects) with media we generated or that is openly licensed. +- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.** + Don't commit new font files to this repo (the Latin UI fonts already in `assets/fonts/` stay): add + the font to craft-fonts (file, licence, manifest line, `ATTRIBUTION.md` row) and use it through + `filmcraft_text::fonts::CRAFT_FONTS`. See [Building with craft-fonts](#building-with-craft-fonts) + and [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). + For each asset file `X`, commit a sidecar `X.attribution`. This is the real one for `assets/fonts/Inter-Regular.ttf`: diff --git a/packaging/env.sh b/packaging/env.sh index cbf02b35..d227a71a 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -44,12 +44,26 @@ warn() { if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi } -# Copy licence and readme files that exist into a package directory. +# Copy licence and readme files that exist into a package directory, plus the licences of the +# craft-fonts fonts the build embedded (only when built with CRAFT_FONTS_DIR). copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi done + copy_font_licences "$dest" +} + +# Copy each craft-fonts licence ($CRAFT_FONTS_DIR/fonts//OFL.txt) to /OFL-.txt. +# Does nothing without CRAFT_FONTS_DIR. +copy_font_licences() { + local dest="$1" lic family + if [ -z "${CRAFT_FONTS_DIR:-}" ]; then return 0; fi + for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do + [ -f "$lic" ] || continue + family="$(basename "$(dirname "$lic")")" + cp "$lic" "$dest/OFL-$family.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 90053421..e9d036be 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -108,6 +108,8 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" +# licences of the embedded craft-fonts fonts (builds with CRAFT_FONTS_DIR) +copy_font_licences "$APP/Contents/Resources" # Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature). # Today the only nested code is the main executable; frameworks/helpers would be signed here too. diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 70aba966..04b31efb 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 } } +# Licences of the embedded craft-fonts fonts (builds with CRAFT_FONTS_DIR) as OFL-.txt. +if ($env:CRAFT_FONTS_DIR) { + foreach ($lic in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -ErrorAction SilentlyContinue) { + Copy-Item $lic.FullName (Join-Path $Portable "OFL-$($lic.Directory.Name).txt") + } +} $Zip = Join-Path $Dist "filmcraft-$Version-windows-$Arch-portable.zip" Remove-Item -Force $Zip -ErrorAction SilentlyContinue Compress-Archive -Path $Portable -DestinationPath $Zip