mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
This commit is contained in:
1 parent
0349b0c0f1
commit
c161eaec73
21 files changed
+533
-14
No files matched your search
@@ -54,6 +54,23 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only.
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
|
||||
# embeds (today the Japanese fonts). An optional build input for everyone else; see
|
||||
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -70,6 +74,12 @@ jobs:
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -90,6 +100,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
|
||||
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
|
||||
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
|
||||
@@ -129,6 +141,12 @@ jobs:
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -153,6 +171,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:
|
||||
@@ -179,6 +199,12 @@ jobs:
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -196,6 +222,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_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: |
|
||||
@@ -264,6 +293,12 @@ jobs:
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
@@ -286,6 +321,8 @@ jobs:
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/photocraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
@@ -320,6 +357,8 @@ jobs:
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
|
||||
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -8,3 +8,4 @@ target/
|
||||
/secrets
|
||||
/temp
|
||||
/book/output
|
||||
/craft-fonts
|
||||
@@ -57,7 +57,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
|
||||
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
|
||||
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
|
||||
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
|
||||
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
|
||||
|
||||
@@ -20,6 +20,19 @@ next to them.
|
||||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
|
||||
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
|
||||
from it is committed here. Per-file authors, sources and licences:
|
||||
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
|
||||
| Font (embedded at build time) | Use | License |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
|
||||
|
||||
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
|
||||
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
|
||||
(CC0-1.0); they are not separate files.
|
||||
|
||||
@@ -16,3 +16,8 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
|
||||
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.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 (today BIZ UDPGothic, Shippori Mincho and
|
||||
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
|
||||
ship with the package as OFL-<family>.txt.
|
||||
@@ -255,6 +255,13 @@ cargo run --release -p photocraft -- image.psd # the desktop app
|
||||
cargo test --workspace # the test suite
|
||||
```
|
||||
|
||||
Japanese fonts for the UI and Type tool come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (desktop release builds always include it). Without it PhotoCraft uses your system's CJK fonts:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
|
||||
```
|
||||
|
||||
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
|
||||
|
||||
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
|
||||
//! `docs/integration.md`; rules in `craftrules/standards/fonts.md`).
|
||||
//!
|
||||
//! With `CRAFT_FONTS_DIR=<craft-fonts checkout>`, every font in its `fonts/manifest.txt` is
|
||||
//! embedded and exposed as `photocraft_text::CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty and the
|
||||
//! build is exactly as before (an empty value counts as unset). Use an absolute path: build
|
||||
//! scripts run in the crate's directory, so a relative one resolves from `crates/text`. A bad
|
||||
//! checkout is a warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds).
|
||||
//!
|
||||
//! PhotoCraft difference from the recipe: the web build (wasm32) embeds nothing. The wasm must
|
||||
//! stay under the 24 MiB gate in `packaging/web/package.sh` (#237; Cloudflare's per-file cap is
|
||||
//! 25 MiB). Measured 2026-10-06 (`trunk build --release`): 24,190,076 bytes
|
||||
//! without craft-fonts, 28,867,488 with only the UI face (BIZ UDPGothic Regular) embedded, over
|
||||
//! the 25,165,824-byte gate. The web build keeps no Japanese font until craft-fonts can be
|
||||
//! served next to the wasm instead of inside it.
|
||||
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 wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty() && !wasm).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 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}"));
|
||||
};
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Fonts from the optional craft-fonts build input (https://github.com/storytold/craft-fonts).
|
||||
//!
|
||||
//! `CRAFT_FONTS` is empty unless the app was built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`
|
||||
//! (see `build.rs`); every user of it must work when it is empty. Today it carries the Japanese
|
||||
//! fonts: BIZ UDPGothic (UI) and Shippori Mincho / BIZ UDMincho (serif document text). The web
|
||||
//! build (wasm32) embeds none of them: they don't fit its size cap (see `build.rs`).
|
||||
|
||||
/// 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 preferred UI family for Japanese.
|
||||
pub const UI_JAPANESE_FAMILY: &str = "BIZ UDPGothic";
|
||||
|
||||
impl CraftFont {
|
||||
/// True for a font meant for Japanese text.
|
||||
pub fn is_japanese(&self) -> bool {
|
||||
self.scripts.contains(&"Jpan")
|
||||
}
|
||||
|
||||
/// True for a Mincho (serif) face.
|
||||
pub fn is_mincho(&self) -> bool {
|
||||
self.family.contains("Mincho")
|
||||
}
|
||||
}
|
||||
|
||||
/// The Japanese craft fonts in UI preference order: BIZ UDPGothic Regular first, then its other
|
||||
/// styles, then the rest in manifest order.
|
||||
pub fn japanese_for_ui() -> Vec<&'static CraftFont> {
|
||||
let mut v: Vec<&CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect();
|
||||
v.sort_by_key(|f| (f.family != UI_JAPANESE_FAMILY, f.style != "Regular"));
|
||||
v
|
||||
}
|
||||
|
||||
/// Japanese craft font families (each once) for document fallback: sans (Gothic) first, then
|
||||
/// Mincho. Serif runs reorder them with [`mincho_first`].
|
||||
pub fn japanese_families() -> Vec<&'static str> {
|
||||
let mut v: Vec<&'static str> = Vec::new();
|
||||
for f in japanese_for_ui() {
|
||||
if !v.contains(&f.family) {
|
||||
v.push(f.family);
|
||||
}
|
||||
}
|
||||
v.sort_by_key(|f| f.contains("Mincho"));
|
||||
v
|
||||
}
|
||||
|
||||
/// `fallback` with the Japanese craft Mincho families moved ahead of the craft Gothic one, in
|
||||
/// place (the CJK script order around them is unchanged). For serif runs.
|
||||
pub fn mincho_first(fallback: &[String]) -> Vec<String> {
|
||||
let craft = japanese_families();
|
||||
let slots: Vec<usize> = fallback.iter().enumerate().filter(|(_, f)| craft.contains(&f.as_str())).map(|(i, _)| i).collect();
|
||||
let mut group: Vec<String> = slots.iter().filter_map(|&i| fallback.get(i).cloned()).collect();
|
||||
group.sort_by_key(|f| !f.contains("Mincho"));
|
||||
let mut out = fallback.to_vec();
|
||||
for (slot, fam) in slots.into_iter().zip(group) {
|
||||
if let Some(o) = out.get_mut(slot) {
|
||||
*o = fam;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A family name that reads as serif (Mincho, Song/Ming, Myeongjo, Times …): its Japanese
|
||||
/// fallback should be a Mincho face rather than a Gothic one.
|
||||
pub fn is_serif_family(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains("sans") || n.contains("gothic") {
|
||||
return false;
|
||||
}
|
||||
const SERIF: &[&str] = &[
|
||||
"serif",
|
||||
"mincho",
|
||||
"minchō",
|
||||
"song",
|
||||
"ming",
|
||||
"myeongjo",
|
||||
"myungjo",
|
||||
"batang",
|
||||
"times",
|
||||
"georgia",
|
||||
"garamond",
|
||||
"minion",
|
||||
"baskerville",
|
||||
"caslon",
|
||||
"bodoni",
|
||||
"didot",
|
||||
"palatino",
|
||||
"cambria",
|
||||
"book antiqua",
|
||||
"century",
|
||||
"hoefler",
|
||||
];
|
||||
SERIF.iter().any(|s| n.contains(s))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mincho_first_reorders_only_craft_families() {
|
||||
let fams = japanese_families();
|
||||
if fams.len() < 2 {
|
||||
eprintln!("skipping: built without craft-fonts (or with only one Japanese family)");
|
||||
return;
|
||||
}
|
||||
let fallback: Vec<String> =
|
||||
["Noto Sans"].iter().map(|s| s.to_string()).chain(fams.iter().map(|s| s.to_string())).chain(["Hiragino Sans".to_string()]).collect();
|
||||
let serif = mincho_first(&fallback);
|
||||
assert_eq!(serif.first().map(String::as_str), Some("Noto Sans"));
|
||||
assert_eq!(serif.last().map(String::as_str), Some("Hiragino Sans"));
|
||||
assert!(serif.get(1).is_some_and(|f| f.contains("Mincho")), "{serif:?}");
|
||||
assert_eq!(serif.len(), fallback.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mincho_first_without_craft_fonts_is_identity() {
|
||||
let fallback = vec!["Noto Sans".to_string(), "Hiragino Sans".to_string()];
|
||||
assert_eq!(mincho_first(&fallback), fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serif_names() {
|
||||
for s in ["Shippori Mincho", "Times New Roman", "Noto Serif JP", "Georgia", "Songti SC", "Adobe Garamond Pro"] {
|
||||
assert!(is_serif_family(s), "{s}");
|
||||
}
|
||||
for s in ["Inter", "Noto Sans Serif", "BIZ UDPGothic", "Helvetica", "", "Noto Sans CJK JP"] {
|
||||
assert!(!is_serif_family(s), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_order_prefers_biz_udpgothic_regular() {
|
||||
let v = japanese_for_ui();
|
||||
let Some(first) = v.first() else {
|
||||
eprintln!("skipping: built without craft-fonts (CRAFT_FONTS is empty)");
|
||||
return;
|
||||
};
|
||||
assert_eq!((first.family, first.style), (UI_JAPANESE_FAMILY, "Regular"));
|
||||
assert!(v.iter().all(|f| f.is_japanese() && !f.bytes.is_empty()));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Font database: bundled fonts (always available, also on the web), optional system fonts found
|
||||
//! by scanning the platform font directories (no fontconfig), user-registered font data, and
|
||||
//! PostScript-name lookup for PSD import.
|
||||
//! Font database: bundled fonts (always available, also on the web), the optional craft-fonts
|
||||
//! Japanese fonts ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional system
|
||||
//! fonts found by scanning the platform font directories (no fontconfig), user-registered font
|
||||
//! data, and PostScript-name lookup for PSD import.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -41,6 +42,11 @@ const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe
|
||||
pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static str> {
|
||||
let mut v = FALLBACK_CANDIDATES.to_vec();
|
||||
for s in order {
|
||||
// craft-fonts' Japanese fonts (if built in) go ahead of the installed Japanese fonts, in
|
||||
// the Japanese slot of the locale order, so shared Han keeps the locale's forms.
|
||||
if *s == crate::cjk::CjkScript::Japanese {
|
||||
v.extend(crate::craft_fonts::japanese_families());
|
||||
}
|
||||
v.extend_from_slice(crate::cjk::families(*s));
|
||||
}
|
||||
v.extend_from_slice(FALLBACK_LAST);
|
||||
@@ -95,6 +101,11 @@ impl FontDb {
|
||||
for (_, bytes) in BUNDLED {
|
||||
db.register_font_data(bytes.to_vec());
|
||||
}
|
||||
// The optional craft-fonts (empty unless built with CRAFT_FONTS_DIR; always empty on
|
||||
// wasm32), before any system font.
|
||||
for f in crate::craft_fonts::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) {
|
||||
db.register_static_font(f.bytes);
|
||||
}
|
||||
db.refresh_generics();
|
||||
db
|
||||
}
|
||||
@@ -134,7 +145,16 @@ impl FontDb {
|
||||
|
||||
/// Registers font data (TTF/OTF, or every face of a TTC/OTC). Returns the family names added.
|
||||
pub fn register_font_data(&mut self, bytes: Vec<u8>) -> Vec<String> {
|
||||
let added = self.fcx.collection.register_fonts(Blob::new(Arc::new(bytes)), None);
|
||||
self.register_blob(Blob::new(Arc::new(bytes)))
|
||||
}
|
||||
|
||||
/// Registers embedded font data without copying it.
|
||||
fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec<String> {
|
||||
self.register_blob(Blob::new(Arc::new(bytes)))
|
||||
}
|
||||
|
||||
fn register_blob(&mut self, blob: Blob<u8>) -> Vec<String> {
|
||||
let added = self.fcx.collection.register_fonts(blob, None);
|
||||
let mut names = Vec::new();
|
||||
for (id, _) in added {
|
||||
if let Some(n) = self.fcx.collection.family_name(id)
|
||||
|
||||
@@ -953,7 +953,12 @@ fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec<Sty
|
||||
if !st.font_family.is_empty() {
|
||||
fam.push(quote(&st.font_family));
|
||||
}
|
||||
fam.extend(fallback.iter().map(|f| quote(f)));
|
||||
// Serif runs fall back to a Mincho face for Japanese (craft-fonts), others to a Gothic one.
|
||||
if crate::craft_fonts::is_serif_family(&st.font_family) {
|
||||
fam.extend(crate::craft_fonts::mincho_first(fallback).iter().map(|f| quote(f)));
|
||||
} else {
|
||||
fam.extend(fallback.iter().map(|f| quote(f)));
|
||||
}
|
||||
fam.push("sans-serif".into());
|
||||
let feats = feature_list(st);
|
||||
let vars: Vec<String> = st.variations.iter().filter(|v| v.axis.len() == 4 && v.axis.is_ascii()).map(|v| format!("\"{}\" {}", v.axis, v.value)).collect();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the
|
||||
//! web), optional system fonts from a directory scan (no fontconfig), user font data, TrueType
|
||||
//! collections, PostScript-name lookup.
|
||||
//! collections, PostScript-name lookup. Builds made with the optional craft-fonts input
|
||||
//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks.
|
||||
//! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking),
|
||||
//! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules.
|
||||
//! * [`render`]: anti-aliased rasterization (exact-area accumulation, f32 coverage) into a
|
||||
@@ -15,6 +16,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cjk;
|
||||
pub mod craft_fonts;
|
||||
pub mod engine_data;
|
||||
pub mod fonts;
|
||||
pub mod glyphs;
|
||||
@@ -30,6 +32,7 @@ pub mod warp;
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_doc::TextLayer;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use fonts::{FaceInfo, FontDb, ResolvedFont};
|
||||
pub use layout::{ClusterInfo, LineInfo, PlacedGlyph, TextLayout};
|
||||
pub use render::Rendered;
|
||||
|
||||
@@ -751,3 +751,63 @@ fn japanese_box_text_preserves_wrap_boundaries() {
|
||||
}
|
||||
assert_eq!(end, text.text.len());
|
||||
}
|
||||
|
||||
/// Which craft-fonts family drew the glyphs of `l` (matched by the embedded bytes).
|
||||
fn craft_family_of(l: &crate::TextLayout) -> Vec<&'static str> {
|
||||
let mut v = Vec::new();
|
||||
for g in &l.glyphs {
|
||||
let Some(face) = l.faces.get(g.face as usize) else { continue };
|
||||
let data: &[u8] = face.font.data.as_ref();
|
||||
if let Some(f) = crate::CRAFT_FONTS.iter().find(|f| std::ptr::eq(f.bytes.as_ptr(), data.as_ptr()) && f.bytes.len() == data.len())
|
||||
&& !v.contains(&f.family)
|
||||
{
|
||||
v.push(f.family);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craft_fonts_cover_japanese_without_system_fonts() {
|
||||
if !crate::CRAFT_FONTS.iter().any(|f| f.is_japanese()) {
|
||||
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
|
||||
return;
|
||||
}
|
||||
let mut e = TextEngine::new();
|
||||
let text = "日本語の文字、カタカナ。";
|
||||
let l = e.layout(&point(text, 24.0), 72.0);
|
||||
assert_eq!(l.glyphs.len(), text.chars().count());
|
||||
assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef with craft-fonts");
|
||||
// Sans (Inter) runs fall back to the Gothic UI family.
|
||||
assert_eq!(craft_family_of(&l), vec![crate::craft_fonts::UI_JAPANESE_FAMILY]);
|
||||
// Serif runs fall back to a Mincho face when the build has one (not the web build).
|
||||
if crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) {
|
||||
let mut t = point(text, 24.0);
|
||||
t.font_family = "Times New Roman".into();
|
||||
let l = e.layout(&t, 72.0);
|
||||
assert!(l.glyphs.iter().all(|g| g.id != 0));
|
||||
let fams = craft_family_of(&l);
|
||||
assert!(fams.len() == 1 && fams[0].contains("Mincho"), "{fams:?}");
|
||||
}
|
||||
// Latin keeps Inter.
|
||||
let l = e.layout(&point("Layer 1", 24.0), 72.0);
|
||||
assert!(craft_family_of(&l).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_without_craft_fonts() {
|
||||
// Whatever the build: the bundled fonts load, Latin lays out, and Japanese never panics
|
||||
// (without craft-fonts or system fonts it may be .notdef).
|
||||
let mut e = TextEngine::new();
|
||||
assert!(e.fonts.has_family("Inter"));
|
||||
let l = e.layout(&point("日本語 Latin", 12.0), 72.0);
|
||||
assert_eq!(l.glyphs.len(), "日本語 Latin".chars().count());
|
||||
for f in crate::CRAFT_FONTS {
|
||||
assert!(e.fonts.has_family(f.family), "{} registered under its manifest name", f.family);
|
||||
}
|
||||
if crate::CRAFT_FONTS.is_empty() {
|
||||
assert!(crate::craft_fonts::japanese_families().is_empty());
|
||||
let fb = fonts::fallback_candidates(&crate::cjk::script_order(Some("ja")));
|
||||
assert!(!fb.iter().any(|f| f.contains("BIZ UD")));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,16 @@
|
||||
//! Japanese font, Hangul a Korean one, Bopomofo a Traditional Chinese one, and Han the
|
||||
//! locale's script (Japanese forms only for a Japanese locale). At most one font is read per
|
||||
//! frame, each file at most once, and once every script has been tried the scan stops.
|
||||
//!
|
||||
//! Builds made with the optional craft-fonts input (`CRAFT_FONTS_DIR`,
|
||||
//! [`photocraft_text::craft_fonts`]) carry Japanese fonts (BIZ UDPGothic first): they are tried
|
||||
//! before the system Japanese fonts, in the Japanese slot of the same locale order. Without
|
||||
//! craft-fonts (and on the web, which never embeds them) nothing changes.
|
||||
|
||||
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
|
||||
use egui::{FontData, FontFamily, FontId, Shape};
|
||||
use photocraft_text::cjk::{self, CjkChar, CjkScript, FontFile};
|
||||
use photocraft_text::craft_fonts::{self, CraftFont};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
|
||||
@@ -28,16 +34,28 @@ pub struct Sources {
|
||||
pub locale: fn() -> Option<String>,
|
||||
pub files: fn(CjkScript) -> Vec<FontFile>,
|
||||
pub last_resort: fn() -> Vec<FontFile>,
|
||||
/// Embedded fonts tried before `files` for a script (craft-fonts' Japanese fonts).
|
||||
pub embedded: fn(CjkScript) -> Vec<&'static CraftFont>,
|
||||
}
|
||||
|
||||
/// craft-fonts' Japanese fonts for the Japanese script, UI face first (empty without craft-fonts).
|
||||
pub fn craft_embedded(script: CjkScript) -> Vec<&'static CraftFont> {
|
||||
if script == CjkScript::Japanese { craft_fonts::japanese_for_ui() } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// No embedded fonts (tests that pin the system-font behaviour).
|
||||
pub fn no_embedded(_: CjkScript) -> Vec<&'static CraftFont> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
impl Sources {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn system() -> Self {
|
||||
Self { locale: || cjk::ui_locale().map(str::to_string), files: cjk::font_files, last_resort: cjk::last_resort_files }
|
||||
Self { locale: || cjk::ui_locale().map(str::to_string), files: cjk::font_files, last_resort: cjk::last_resort_files, embedded: craft_embedded }
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn system() -> Self {
|
||||
Self { locale: || None, files: |_| Vec::new(), last_resort: Vec::new }
|
||||
Self { locale: || None, files: |_| Vec::new(), last_resort: Vec::new, embedded: craft_embedded }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +96,9 @@ impl CjkFallback {
|
||||
continue;
|
||||
}
|
||||
self.tried.push(s);
|
||||
if let Some(f) = self.load_embedded(s) {
|
||||
return Some(f);
|
||||
}
|
||||
let files = (self.sources.files)(s);
|
||||
if let Some(f) = self.load_first(&files) {
|
||||
return Some(f);
|
||||
@@ -91,6 +112,20 @@ impl CjkFallback {
|
||||
None
|
||||
}
|
||||
|
||||
/// The first embedded (craft-fonts) font for `s`, registered from its static bytes.
|
||||
fn load_embedded(&mut self, s: CjkScript) -> Option<(String, FontData)> {
|
||||
let f = (self.sources.embedded)(s).into_iter().find(|f| !f.bytes.is_empty())?;
|
||||
let path = PathBuf::from(format!("craft-fonts/{} {}", f.family, f.style));
|
||||
if self.loaded.contains(&path) {
|
||||
return None;
|
||||
}
|
||||
self.loaded.push(path.clone());
|
||||
let name = format!("{FONT_PREFIX}-{}", self.registered.len());
|
||||
log::info!("UI font fallback: registered embedded {} as {name}", path.display());
|
||||
self.registered.push((name.clone(), path, 0));
|
||||
Some((name, FontData::from_static(f.bytes)))
|
||||
}
|
||||
|
||||
fn load_first(&mut self, files: &[FontFile]) -> Option<(String, FontData)> {
|
||||
for f in files {
|
||||
if self.loaded.contains(&f.path) {
|
||||
@@ -270,6 +305,7 @@ mod tests {
|
||||
_ => vec![],
|
||||
},
|
||||
last_resort: || vec![fake("font.ttf")],
|
||||
embedded: no_embedded,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +335,7 @@ mod tests {
|
||||
("zh-Hant-TW", CjkScript::TraditionalChinese),
|
||||
("ko_KR", CjkScript::Korean),
|
||||
] {
|
||||
let mut fb = CjkFallback::new(Sources { locale: || None, files: |_| vec![], last_resort: Vec::new });
|
||||
let mut fb = CjkFallback::new(Sources { locale: || None, files: |_| vec![], last_resort: Vec::new, embedded: no_embedded });
|
||||
fb.order = Some(cjk::script_order(Some(loc)));
|
||||
assert!(fb.next_font(CjkChar::Han).is_none());
|
||||
assert_eq!(fb.tried[0], first, "{loc}");
|
||||
@@ -355,6 +391,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// No system fonts at all: craft-fonts alone draws Japanese.
|
||||
fn craft_only(locale: fn() -> Option<String>) -> Sources {
|
||||
Sources { locale, files: |_| vec![], last_resort: Vec::new, embedded: craft_embedded }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craft_fonts_render_japanese_without_system_fonts() {
|
||||
let Some(ui_font) = craft_fonts::japanese_for_ui().first().copied() else {
|
||||
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
|
||||
return;
|
||||
};
|
||||
// An English locale tries Chinese first (none here), then Japanese: the craft font.
|
||||
for locale in [(|| Some("en_US".into())) as fn() -> Option<String>, || Some("ja_JP".into()), || None] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts_with(&ctx, craft_only(locale));
|
||||
assert!(render(&ctx, "日本語の文字"), "tofu in 日本語の文字");
|
||||
assert!(render(&ctx, "レイヤー 1"), "tofu in kana");
|
||||
let fonts = ctx.fonts(|f| f.definitions().clone());
|
||||
let name = format!("{FONT_PREFIX}-0");
|
||||
assert!(fonts.font_data.get(&name).is_some_and(|d| std::ptr::eq(d.font.as_ref().as_ptr(), ui_font.bytes.as_ptr())), "BIZ UDPGothic Regular first");
|
||||
for (fam, stack) in &fonts.families {
|
||||
assert_eq!(stack.last(), Some(&name), "{fam:?}: appended last so Latin keeps Inter");
|
||||
assert!(stack.first().is_some_and(|f| f != &name), "{fam:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_works_without_craft_fonts() {
|
||||
// The pre-craft-fonts behaviour: no embedded fonts, no system fonts. Latin renders,
|
||||
// Japanese is tried (and stays missing) without panicking, and the loader exhausts.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts_with(&ctx, Sources { locale: || None, files: |_| vec![], last_resort: Vec::new, embedded: no_embedded });
|
||||
assert!(render(&ctx, "Layer 1 – café"));
|
||||
assert!(!render(&ctx, "日本語の文字"));
|
||||
let n = ctx.fonts(|f| f.definitions().font_data.keys().filter(|k| k.starts_with(FONT_PREFIX)).count());
|
||||
assert_eq!(n, 0);
|
||||
if craft_fonts::CRAFT_FONTS.is_empty() {
|
||||
assert!(craft_embedded(CjkScript::Japanese).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// With the real system fonts, every CJK sample renders (skipped per script when the
|
||||
/// machine has no font for it, e.g. CI Linux without Noto CJK).
|
||||
#[test]
|
||||
|
||||
@@ -273,6 +273,11 @@ impl Tokens {
|
||||
|
||||
/// Register Inter (UI) and JetBrains Mono (numbers) plus named weights.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
install_fonts_with(ctx, crate::cjk_fonts::Sources::system());
|
||||
}
|
||||
|
||||
/// [`install_fonts`] with the CJK fallback fonts taken from `cjk` (tests swap the sources).
|
||||
pub fn install_fonts_with(ctx: &egui::Context, cjk: crate::cjk_fonts::Sources) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
|
||||
@@ -291,8 +296,9 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
fonts.families.insert(FontFamily::Name(fam.into()), stack);
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
// Japanese / Chinese / Korean system fonts are registered on demand (cjk_fonts.rs).
|
||||
crate::cjk_fonts::install(ctx);
|
||||
// Japanese / Chinese / Korean fallback fonts (craft-fonts' Japanese ones if built in, then
|
||||
// the system's) are registered on demand (cjk_fonts.rs).
|
||||
crate::cjk_fonts::install_with(ctx, cjk);
|
||||
}
|
||||
|
||||
pub fn medium(size: f32) -> FontId {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- **Language:** Rust only. No JavaScript or TypeScript. On the web, `wasm-bindgen` generates a small loader; never hand-write JS.
|
||||
- **Licence:** contributions are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
|
||||
- **Clean-room:** do not copy code, shaders, icons, ICC profiles or other assets from proprietary software (such as Photoshop). Match behaviour and look by observation and public specs. Only use assets with permissive licences, keep their license next to the asset (e.g. `assets/fonts/OFL-*.txt`, `assets/icons/LICENSE-lucide.txt`), and list every asset in `ATTRIBUTION.md`.
|
||||
- **Clean-room:** do not copy code, shaders, icons, ICC profiles or other assets from proprietary software (such as Photoshop). Match behaviour and look by observation and public specs. Only use assets with permissive licences, keep their license next to the asset (e.g. `assets/fonts/OFL-*.txt`, `assets/icons/LICENSE-lucide.txt`), and list every asset in `ATTRIBUTION.md`. **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo: add new fonts there, and build with them through the optional `CRAFT_FONTS_DIR` build input (`docs/development.md` › Fonts; rules in `../craftrules/standards/fonts.md`).
|
||||
- **Never crash:** non-test code must not panic: no `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`. Errors go through `Result` and `?`, input-derived indices and sizes are checked, and every crash fix ships with a regression test. See *Never crash* in `AGENTS.md`.
|
||||
- **Commands, not handlers:** new features are engine commands with tests, and the UI calls them (checklist below).
|
||||
- **Layering:** `cargo xtask layers` must pass. Register new crates in `xtask/src/layers.rs`.
|
||||
|
||||
+20
-1
@@ -19,6 +19,25 @@ cargo xtask parity # Photoshop menu cove
|
||||
|
||||
Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive.
|
||||
|
||||
## Fonts (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 (Inter and JetBrains Mono in `assets/fonts/` are the only exceptions; new fonts go to craft-fonts). The rules are in [`craftrules/standards/fonts.md`](../../craftrules/standards/fonts.md) ([on GitHub](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)).
|
||||
|
||||
craft-fonts is an **optional build input**, never a Cargo dependency:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japanese font tests too
|
||||
```
|
||||
|
||||
- Use an absolute path (`$PWD/...`): `build.rs` runs in `crates/text`, so a relative `CRAFT_FONTS_DIR` would resolve from there.
|
||||
- `crates/text/build.rs` reads `$CRAFT_FONTS_DIR/fonts/manifest.txt` and embeds the fonts as `photocraft_text::CRAFT_FONTS` (`crates/text/src/craft_fonts.rs`). Unset, `CRAFT_FONTS` is empty and the build is unchanged. A bad path is a build warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both).
|
||||
- **UI:** the Japanese fonts (BIZ UDPGothic Regular first) are the first Japanese fallback in the lazy CJK loader (`crates/ui-egui/src/cjk_fonts.rs`), ahead of the system Japanese fonts and in the same locale script order, appended last to every egui family with the usual baseline alignment.
|
||||
- **Type tool:** the text engine registers them in `FontDb::new` (so also with no system fonts) and puts them first in the Japanese slot of the locale-ordered fallback list: BIZ UDPGothic for sans runs, Shippori Mincho / BIZ UDMincho for serif runs.
|
||||
- **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. The web build therefore has no Japanese font yet (loading craft-fonts next to the wasm at run time would be the way to add one).
|
||||
- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI's Linux job runs the tests a second time with `CRAFT_FONTS_DIR` set. Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-<family>.txt`.
|
||||
|
||||
## Graphics startup and device loss
|
||||
|
||||
`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering it in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state.
|
||||
@@ -257,7 +276,7 @@ trunk build --release # writes ../../dist/web (index.html, .js glue
|
||||
trunk serve --release # dev server on http://127.0.0.1:8765
|
||||
```
|
||||
|
||||
Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "s" except the pixel crates). The `.wasm` is about 18.8 MiB raw, 7.8 MiB gzipped and 5.6 MiB with Brotli; serve it with compression. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names).
|
||||
Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "s" except the pixel crates). The `.wasm` is about 18.8 MiB raw, 7.8 MiB gzipped and 5.6 MiB with Brotli; serve it with compression. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names).
|
||||
|
||||
URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path.
|
||||
|
||||
|
||||
@@ -61,6 +61,14 @@ Every binary reports its version, the commit and the build date: `photocraft --v
|
||||
`PHOTOCRAFT_BUILD_DATE`, and `crates/engine/src/build_info.rs` reads them at compile time. A plain
|
||||
`cargo build` doesn't set them and reports `0.2.0 (dev build)`.
|
||||
|
||||
Every desktop build job (macOS, Windows, Linux, FreeBSD) also checks out [craft-fonts](https://github.com/storytold/craft-fonts)
|
||||
at the commit in `CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and
|
||||
`CRAFT_FONTS_REQUIRED=1`, so desktop releases embed its Japanese fonts (the web build embeds none: see
|
||||
`docs/development.md` › Fonts) and fail rather than ship without them. The packages carry each font's licence as
|
||||
`OFL-<family>.txt` (`copy_font_licences` in `packaging/env.sh`; the portable zip on Windows;
|
||||
`Contents/Resources/Licenses` in the macOS app). Bump the pin deliberately, together with the one
|
||||
in `ci.yml`. Rules: `../craftrules/standards/fonts.md`; build option: `docs/development.md` › Fonts.
|
||||
|
||||
### macOS
|
||||
|
||||
`packaging/macos/package.sh` builds `aarch64-apple-darwin` and `x86_64-apple-darwin` with
|
||||
|
||||
@@ -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_font_licences "$dest"
|
||||
}
|
||||
|
||||
# Builds made with craft-fonts (CRAFT_FONTS_DIR, all official releases) embed its fonts, which
|
||||
# are OFL-1.1: ship each font's licence as OFL-<family-dir>.txt. No-op without CRAFT_FONTS_DIR.
|
||||
copy_font_licences() {
|
||||
local dest="$1" lic fam
|
||||
[ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
|
||||
for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
|
||||
[ -f "$lic" ] || continue
|
||||
fam="$(basename "$(dirname "$lic")")"
|
||||
cp "$lic" "$dest/OFL-$fam.txt"
|
||||
done
|
||||
}
|
||||
|
||||
# Portable SHA-256 of a file (prints just the hash).
|
||||
|
||||
@@ -103,6 +103,11 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile).
|
||||
cp "$WORK/bin/photocraft" "$APP/Contents/MacOS/PhotoCraft"
|
||||
cp "$ROOT/assets/app-icon/photocraft.icns" "$APP/Contents/Resources/PhotoCraft.icns"
|
||||
# Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR).
|
||||
if [ -n "${CRAFT_FONTS_DIR:-}" ]; then
|
||||
mkdir -p "$APP/Contents/Resources/Licenses"
|
||||
copy_font_licences "$APP/Contents/Resources/Licenses"
|
||||
fi
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
|
||||
-e "s/@BUILD_SHA@/${PHOTOCRAFT_BUILD_SHA:-unknown}/g" \
|
||||
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
|
||||
|
||||
@@ -114,6 +114,14 @@ 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 (CRAFT_FONTS_DIR, all official releases) embed its OFL-1.1 fonts:
|
||||
# ship each font's licence as OFL-<family-dir>.txt.
|
||||
if ($env:CRAFT_FONTS_DIR) {
|
||||
Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts') -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$lic = Join-Path $_.FullName 'OFL.txt'
|
||||
if (Test-Path $lic) { Copy-Item $lic (Join-Path $Portable "OFL-$($_.Name).txt") }
|
||||
}
|
||||
}
|
||||
# portable.txt beside photocraft.exe switches on portable mode: settings, presets and recovery
|
||||
# files go to PhotoCraftData\ next to the exe instead of %APPDATA% (#228; see app_dirs.rs).
|
||||
Copy-Item (Join-Path $PSScriptRoot 'portable.txt') $Portable
|
||||
|
||||
Reference in new issue
Block a user