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-<family>.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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2f6ba8b44
commit
9953befe28
15 files changed
+356
-10
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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=<craft-fonts checkout>` (`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
|
||||
|
||||
|
||||
@@ -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-<family>.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
|
||||
|
||||
@@ -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<T, E>` 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 `<file>.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`.
|
||||
|
||||
@@ -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-<family>.txt.
|
||||
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.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).
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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=<craft-fonts checkout>` 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<String, String> {
|
||||
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<String> = 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)
|
||||
}
|
||||
@@ -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<Item = &'static CraftFont> {
|
||||
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<Db> {
|
||||
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<Resolved> {
|
||||
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<Face>> = 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<FontFamily> {
|
||||
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::<usize>(), 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:")));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`:
|
||||
|
||||
|
||||
+15
-1
@@ -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/<family>/OFL.txt) to <dest>/OFL-<family>.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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<family>.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
|
||||
|
||||
Reference in new issue
Block a user