fix #425: Restore Simplified Chinese font rendering

1.Added Simplified Chinese font support for web and desktop builds.
2.Kept Inter as the default font, with Chinese used only as a fallback.
3.Verified Chinese glyph tests and a macOS universal build.

Signed-off-by: supper_yijie <[email protected]>
This commit is contained in:
supper_yijie committed 2026-10-09 15:12:33 +08:00
1 parent c435d143de
commit bf35a3c6c5
16 files changed
+267 -43

No files matched your search

+4 -3
View File
@@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
+4 -3
View File
@@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
without it; the FreeBSD CI job runs them with it. Rules: craftrules
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
+10 -2
View File
@@ -24,13 +24,21 @@
<script type="module">
import init, { start, command } from "./lightcraft_web.js";
(async () => {
// The Chinese font is a separate file: embedding it would exceed the host's WASM size limit.
// Font-free development builds can still run, with the same missing-glyph warning as before.
const chineseFont = fetch("./lightcraft_zh_hans.otf")
.then(async response => response.ok ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array())
.catch(() => new Uint8Array());
// Compile once; the render workers (worker.js) instantiate the same compiled module.
const module = await WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm"));
const [module, font] = await Promise.all([
WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm")),
chineseFont,
]);
globalThis.lightcraftModule = module;
await init({ module_or_path: module });
// automation: `await lightcraft.command("library.info", "{}")` → JSON text
globalThis.lightcraft = { command };
start();
start(font);
})().catch((e) => {
console.error(e);
const el = document.getElementById("lightcraft_loading");
+9 -5
View File
@@ -378,7 +378,7 @@ struct WebApp {
}
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
let Boot { opts, backend, files, index, lib_dir } = boot;
let originals = Originals::default();
let t = perf_now();
@@ -423,7 +423,8 @@ impl WebApp {
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
session.media.preview_capacity = 3;
let frozen = Rc::new(Cell::new(false));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
.with_chinese_font(chinese_font);
let ui_written = files.get("ui.json").unwrap_or_default();
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
app.ui = ui;
@@ -656,12 +657,15 @@ impl eframe::App for WebApp {
}
}
/// Start the app (called by `index.html` after instantiating the module).
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
#[wasm_bindgen]
pub fn start() {
pub fn start(chinese_font: Vec<u8>) {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
safety::install_panic_hook();
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
if chinese_font.is_empty() {
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
}
let opts = Options::from_url();
wasm_bindgen_futures::spawn_local(async move {
// one tab per library: two would each keep their own copy and overwrite each other's saves
@@ -682,7 +686,7 @@ pub fn start() {
};
let boot = boot(opts).await;
let runner = eframe::WebRunner::new();
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot, chinese_font))))).await;
match r {
Ok(()) => {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
+2 -2
View File
@@ -21,12 +21,12 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; embedded only in builds made with `CRAFT_FONTS_DIR` (all official releases); the web (wasm32) build embeds BIZ UDPGothic Regular only |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
- Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho) are not in this repository. They live in
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its
`ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official
releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`).
+16 -3
View File
@@ -13,19 +13,22 @@ 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 required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
let mut embedded = 0;
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from).map(resolve) {
match craft_fonts(&dir) {
match craft_fonts(&dir, required) {
Ok(entries) => {
embedded = entries.lines().count();
src.push_str(&entries);
}
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
Err(e) if required => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
} else if required {
println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing");
}
src.push_str("];\n");
// Without them the app still runs and every language still selectable, so Chinese and Japanese
@@ -58,17 +61,21 @@ fn resolve(dir: PathBuf) -> PathBuf {
}
/// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &Path) -> Result<String, String> {
fn craft_fonts(dir: &Path, required: bool) -> 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").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
let mut has_japanese_ui = false;
let mut has_chinese_ui = false;
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}"));
};
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
continue;
}
@@ -82,5 +89,11 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
path.display().to_string(),
);
}
if required && !has_japanese_ui {
return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into());
}
if required && !wasm && !has_chinese_ui {
return Err("required Simplified Chinese UI face Noto Sans CJK SC Regular (Hans) is missing from fonts/manifest.txt".into());
}
Ok(out)
}
+13 -2
View File
@@ -206,6 +206,8 @@ pub struct LightcraftApp {
/// The language the installed fonts were built for: the CJK fallback order follows the UI
/// language's script, so switching language reinstalls them.
font_language: i18n::Locale,
/// Browser font loaded separately from WASM so the module stays below its host's size limit.
chinese_font: Option<std::sync::Arc<egui::FontData>>,
fonts_ready: bool,
last_time: f64,
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
@@ -291,6 +293,7 @@ impl LightcraftApp {
synthetic_mods_release: false,
styled: false,
font_language: i18n::Locale::En,
chinese_font: None,
fonts_ready: false,
last_time: 0.0,
canvas_rect: None,
@@ -322,6 +325,14 @@ impl LightcraftApp {
}
}
/// Supply the browser's separately downloaded Simplified Chinese font before the first frame.
pub fn with_chinese_font(mut self, bytes: Vec<u8>) -> Self {
if !bytes.is_empty() {
self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes)));
}
self
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
// keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them
@@ -685,7 +696,7 @@ impl LightcraftApp {
fn logic_inner(&mut self, ctx: &egui::Context) {
if !self.styled {
theme::install_fonts(ctx);
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
theme::apply(ctx);
// File → Add from Device lists cards scanned in the background: show hot-plugs
let repaint = ctx.clone();
@@ -699,7 +710,7 @@ impl LightcraftApp {
} else if self.font_language != self.ui.language {
// Shared Han characters take the active language's forms (Japanese faces for 日本語,
// the Simplified Chinese face for 简体中文): rebuild the fallback order.
theme::install_fonts(ctx);
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
self.font_language = self.ui.language;
} else {
self.fonts_ready = true;
+4 -1
View File
@@ -763,7 +763,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
_ => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(crate::i18n::tr_format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.label(crate::i18n::tr_format!(
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.",
crate::theme::font_credits(app.chinese_font.is_some())
));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
+128 -10
View File
@@ -6,6 +6,7 @@ use std::sync::Arc;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
pub const FONT_SEMIBOLD: &str = "semibold";
const WEB_CHINESE_FONT: &str = "web Noto Sans CJK SC";
/// Shared colour-label palette for badges, thumbnail surrounds and feedback.
pub fn label_color(label: lightcraft_catalog::ColorLabel) -> Color32 {
@@ -129,14 +130,24 @@ impl Tokens {
}
pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS));
install_fonts_with_chinese(ctx, None);
}
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last
/// fallback of every family — the active language's own script first, so shared Han characters keep
/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as
/// boxes.
/// Install the browser's separately downloaded Chinese face alongside the embedded faces.
pub fn install_fonts_with_chinese(ctx: &egui::Context, chinese: Option<&Arc<FontData>>) {
ctx.set_fonts(font_definitions_with_chinese(lightcraft_engine::CRAFT_FONTS, chinese));
}
/// Inter remains the default UI face. Egui's default faces and craft-fonts cover other scripts;
/// the browser's separate Chinese face precedes Japanese faces within the CJK fallback list.
/// Without craft-fonts or that separate face, CJK text shows boxes.
pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions {
font_definitions_with_chinese(craft, None)
}
/// The web build keeps the large Simplified Chinese face outside the size-limited WASM module.
/// Prefer it for Chinese UI text, including Traditional Chinese until it has its own face.
pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFont], chinese: Option<&Arc<FontData>>) -> FontDefinitions {
let mut fonts = FontDefinitions::default();
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
@@ -147,12 +158,24 @@ pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontD
.map(craft_font_name)
.collect::<Vec<String>>()
};
let (regular, bold) = (fallback("Regular"), fallback("Bold"));
let (mut regular, mut bold) = (fallback("Regular"), fallback("Bold"));
for name in regular.iter().chain(bold.iter()) {
if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) {
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes)));
}
}
let script = crate::i18n::language().script();
let prefer_chinese = chinese.is_some() && matches!(script, "Hans" | "Hant");
if let Some(font) = chinese {
fonts.font_data.insert(WEB_CHINESE_FONT.into(), font.clone());
if prefer_chinese {
regular.insert(0, WEB_CHINESE_FONT.into());
bold.insert(0, WEB_CHINESE_FONT.into());
} else {
regular.push(WEB_CHINESE_FONT.into());
bold.push(WEB_CHINESE_FONT.into());
}
}
let defaults: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut prop = vec!["Inter".to_string()];
prop.extend(defaults.iter().cloned());
@@ -162,19 +185,29 @@ pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontD
semi.extend(defaults);
semi.extend(bold);
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular"));
let mono = fonts.families.entry(FontFamily::Monospace).or_default();
let mut mono_fallback = fallback("Regular");
if prefer_chinese {
mono_fallback.insert(0, WEB_CHINESE_FONT.into());
}
if chinese.is_some() && !prefer_chinese {
mono_fallback.push(WEB_CHINESE_FONT.into());
}
mono.extend(mono_fallback);
fonts
}
/// The embedded font families for the About box: Inter, plus the craft-fonts families when built
/// with them.
pub fn font_credits() -> String {
/// The font families used by this app instance, including the browser's separately loaded face.
pub fn font_credits(chinese_loaded: bool) -> String {
let mut families = vec!["Inter"];
for f in lightcraft_engine::CRAFT_FONTS {
if !families.contains(&f.family) {
families.push(f.family);
}
}
if chinese_loaded && !families.contains(&"Noto Sans CJK SC") {
families.push("Noto Sans CJK SC");
}
families.join(" / ")
}
@@ -225,3 +258,88 @@ pub fn apply(ctx: &egui::Context) {
s.animation_time = 0.08;
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::{Locale, set_language};
#[test]
fn native_chinese_face_falls_back_after_default_faces() {
static FACES: &[lightcraft_engine::CraftFont] = &[
lightcraft_engine::CraftFont {
family: "BIZ UDPGothic",
style: "Regular",
scripts: &["Jpan", "Latn"],
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
},
lightcraft_engine::CraftFont {
family: "Noto Sans CJK SC",
style: "Regular",
scripts: &["Hans", "Latn"],
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
},
];
set_language(Locale::ZhHans);
let definitions = font_definitions(FACES);
let defaults = FontDefinitions::default();
let default_prop = defaults.families.get(&FontFamily::Proportional).expect("default proportional fonts");
let chinese = "craft-fonts Noto Sans CJK SC Regular";
for (family, primary) in [(FontFamily::Proportional, "Inter"), (FontFamily::Name(FONT_SEMIBOLD.into()), "Inter-SemiBold")] {
let names = definitions.families.get(&family).expect("UI font family");
assert_eq!(names.first().map(String::as_str), Some(primary));
assert_eq!(names.get(1), default_prop.first());
assert!(names.iter().position(|name| name == chinese).is_some_and(|position| position > default_prop.len()));
}
let mono = definitions.families.get(&FontFamily::Monospace).expect("monospace font family");
let default_mono = defaults.families.get(&FontFamily::Monospace).expect("default monospace fonts");
assert_eq!(mono.first(), default_mono.first());
assert!(mono.iter().position(|name| name == chinese).is_some_and(|position| position >= default_mono.len()));
set_language(Locale::En);
}
#[test]
fn separate_web_chinese_face_is_available_in_every_ui_family() {
// Simulate the WASM build, which embeds only the Japanese face. The Chinese face arrives
// as a separate file and must precede that face when the UI uses Chinese.
static JAPANESE: &[lightcraft_engine::CraftFont] = &[lightcraft_engine::CraftFont {
family: "BIZ UDPGothic",
style: "Regular",
scripts: &["Jpan", "Latn"],
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
}];
let embedded = lightcraft_engine::CRAFT_FONTS.iter().find(|font| font.family == "Noto Sans CJK SC" && font.style == "Regular");
let fallback: &'static [u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let source = embedded.map_or(fallback, |font| font.bytes);
let chinese = Arc::new(FontData::from_static(source));
set_language(Locale::ZhHans);
let definitions = font_definitions_with_chinese(JAPANESE, Some(&chinese));
let default_count = FontDefinitions::default().families.get(&FontFamily::Proportional).map_or(0, Vec::len);
for (family, primary) in [(FontFamily::Proportional, "Inter"), (FontFamily::Name(FONT_SEMIBOLD.into()), "Inter-SemiBold")] {
let names = definitions.families.get(&family).expect("UI font family");
assert_eq!(names.first().map(String::as_str), Some(primary));
assert_eq!(names.get(1 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT));
}
assert!(definitions.families.get(&FontFamily::Monospace).is_some_and(|names| names.contains(&WEB_CHINESE_FONT.to_string())));
if embedded.is_some() {
let ctx = egui::Context::default();
ctx.set_fonts(definitions);
let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {});
frame.textures_delta.clear();
ctx.fonts_mut(|fonts| {
for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] {
let id = FontId::new(13.0, family);
assert!("简体中文字".chars().all(|ch| fonts.has_glyph(&id, ch)), "{id:?}");
}
});
}
set_language(Locale::Ja);
let japanese = font_definitions_with_chinese(JAPANESE, Some(&chinese));
let names = japanese.families.get(&FontFamily::Proportional).expect("proportional fonts");
assert_eq!(names.get(1 + default_count).map(String::as_str), Some("craft-fonts BIZ UDPGothic Regular"));
assert_eq!(names.get(2 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT));
set_language(Locale::En);
}
}
+3 -3
View File
@@ -12,9 +12,9 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
`cargo::warning` 提示这一点。
- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
原生桌面版不受影响。
- 网页版将 Noto Sans CJK SC 作为独立的 `lightcraft_zh_hans.otf` 文件随站点分发,页面启动时加载;
这样可显示简体中文,也避免把约 16 MB 字体放进受单文件大小限制的 WASM。部署时需上传整个
`target/web/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
## 翻译的维护
+1 -1
View File
@@ -22,7 +22,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
craft-fonts 目前沒有繁體中文專用字型:桌面版先以 Noto Sans CJK SC 顯示繁中文字(字形為大陸規範寫法),其次才是日文字型;不在此儲存庫新增字型檔。
未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。
Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
Web 建置在 WASM 中嵌入 BIZ UDPGothic Regular,並另外載入 Noto Sans CJK SC;繁中文字形仍採用簡中規範寫法。
## 自動化與維護
+1 -1
View File
@@ -527,7 +527,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
+3 -1
View File
@@ -57,7 +57,9 @@ workflow, and bump it (in `release.yml`, five jobs, and `freebsd.yml`) whenever
shipped language needs: a stale pin still builds, it just ships tofu (issue #319: v0.4.0 pinned a revision
from before Noto Sans CJK SC, so Simplified Chinese had no glyphs). Before a release, check the pin against
craft-fonts' `fonts/manifest.txt` and run `CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n`,
whose glyph-coverage test fails when a language's characters have no face.
whose glyph-coverage test checks the characters in available faces. `CRAFT_FONTS_REQUIRED=1` now also makes
the native build fail if the pinned manifest lacks Noto Sans CJK SC Regular (`Hans`) or BIZ UDPGothic Regular;
the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a separate asset.
## Signing credentials
+6 -5
View File
@@ -52,13 +52,14 @@ codecs) at opt-level 3 and the rest (egui, eframe, serde, glue) at "s". What goe
is the brotli column: 3.1 MB, 11 % less than the old build's brotli size and 41 % less than its
gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further.
**Fonts.** The browser has no system fonts to fall back on, so Japanese text comes from
**Fonts.** The browser has no system fonts to fall back on, so CJK text comes from
[craft-fonts](https://github.com/storytold/craft-fonts), the optional `CRAFT_FONTS_DIR` build
input (`CRAFT_FONTS_DIR=../craft-fonts cargo xtask web`; release builds always set it). On wasm32
`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI, and the watermark fallback), so
the module stays well under Cloudflare's 25 MiB per-file limit: measured 2026-10-06, 17.1 MB
without craft-fonts and 21.8 MB with it (brotli 3.8 MB / 6.3 MB). Without it the web build
works, but Japanese text has no glyphs.
`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI and watermark fallback). The web
bundle also ships Noto Sans CJK SC Regular as `lightcraft_zh_hans.otf` and loads it before the UI
starts. Keeping the 16 MB Chinese face separate leaves the module under Cloudflare's 25 MiB
per-file limit (the older Japanese-only module measured 21.8 MB). Without `CRAFT_FONTS_DIR`, the
web build still works, but Chinese and Japanese text lack glyphs.
## Deploying: headers
+2
View File
@@ -15,6 +15,8 @@ static site in `lightcraft-web-<version>/`:
| `lightcraft_web.js` | wasm-bindgen glue (generated, ES module) |
| `lightcraft_web_bg.wasm` | The app, about 13 MB (about 3 MB with brotli) |
| `worker.js` | Starts the render workers (each runs the same module) |
| `lightcraft_zh_hans.otf` | Simplified Chinese UI font, included when built with `CRAFT_FONTS_DIR` |
| `OFL-noto-sans-cjk-sc.txt` | The bundled Chinese font's SIL Open Font License |
| `*.gz`, `*.br` | Precompressed copies of the files above (optional to serve) |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
+61 -1
View File
@@ -13,6 +13,8 @@ use std::process::Command;
use crate::{cargo, metadata, root, run as step};
const TARGET: &str = "wasm32-unknown-unknown";
const CHINESE_FONT_FILE: &str = "lightcraft_zh_hans.otf";
const CHINESE_FONT_LICENSE_FILE: &str = "OFL-noto-sans-cjk-sc.txt";
/// Files copied from `apps/lightcraft-web/` into the bundle as they are.
const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"];
@@ -20,6 +22,51 @@ const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"];
/// Bundle files that get precompressed copies.
const COMPRESSED: [&str; 4] = ["index.html", "worker.js", "lightcraft_web.js", "lightcraft_web_bg.wasm"];
/// The Noto face is shipped beside the WASM rather than embedded in it: hosts such as Cloudflare
/// Pages cap individual files at 25 MiB, while the complete site can contain larger assets.
fn chinese_font_in_manifest(manifest: &str) -> Option<(&str, &str)> {
manifest.lines().find_map(|line| {
let fields: Vec<_> = line.split('|').map(str::trim).collect();
if fields.len() >= 6 && fields[0] == "Noto Sans CJK SC" && fields[1] == "Regular" && !fields[2].is_empty() && !fields[5].is_empty() {
Some((fields[2], fields[5]))
} else {
None
}
})
}
fn copy_chinese_font(out: &Path) -> Result<bool, String> {
// A previous font-enabled build must not leave an unlicensed/stale asset in a font-free one.
for name in [CHINESE_FONT_FILE, CHINESE_FONT_LICENSE_FILE, "lightcraft_zh_hans.otf.gz", "lightcraft_zh_hans.otf.br"] {
let path = out.join(name);
match std::fs::remove_file(&path) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(format!("{}: {e}", path.display())),
}
}
let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR") else {
if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() {
return Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into());
}
return Ok(false);
};
let dir = PathBuf::from(dir);
let dir = if dir.is_absolute() { dir } else { root().join(dir) };
let manifest_path = dir.join("fonts/manifest.txt");
let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
let (font, licence) =
chinese_font_in_manifest(&manifest).ok_or_else(|| format!("{}: Noto Sans CJK SC Regular is missing", manifest_path.display()))?;
let licence = dir.join(licence);
let licence_target = out.join(CHINESE_FONT_LICENSE_FILE);
std::fs::copy(&licence, &licence_target).map_err(|e| format!("{} → {}: {e}", licence.display(), licence_target.display()))?;
let source = dir.join(font);
let target = out.join(CHINESE_FONT_FILE);
std::fs::copy(&source, &target).map_err(|e| format!("{} → {}: {e}", source.display(), target.display()))?;
println!("web Chinese font: {} → {}", source.display(), target.display());
Ok(true)
}
/// The `wasm-bindgen` version pinned in Cargo.lock (the CLI must match it exactly).
fn locked_bindgen_version() -> Result<String, String> {
let lock = std::fs::read_to_string(root().join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?;
@@ -84,10 +131,11 @@ pub fn run(args: &[&str]) -> Result<(), String> {
for f in STATIC_FILES {
std::fs::copy(root().join("apps/lightcraft-web").join(f), out.join(f)).map_err(|e| format!("copy {f}: {e}"))?;
}
let chinese_font = copy_chinese_font(&out)?;
// precompressed copies (skipped for --dev: they'd only slow the edit loop down)
println!("\n{:<26} {:>12} {:>12} {:>12}", "file", "bytes", "gzip -9", "brotli -11");
for f in COMPRESSED {
for f in COMPRESSED.into_iter().chain(chinese_font.then_some(CHINESE_FONT_FILE)) {
let p = out.join(f);
let raw = std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?;
let (gz, br) = if dev {
@@ -138,6 +186,8 @@ fn mime(path: &Path) -> &'static str {
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"otf" => "font/otf",
"txt" => "text/plain; charset=utf-8",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
}
@@ -242,6 +292,16 @@ mod tests {
assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm");
assert_eq!(mime(Path::new("index.html")), "text/html; charset=utf-8");
assert_eq!(mime(Path::new("worker.js")), "text/javascript; charset=utf-8");
assert_eq!(mime(Path::new(CHINESE_FONT_FILE)), "font/otf");
}
#[test]
fn web_bundle_selects_the_chinese_face_from_the_font_manifest() {
let manifest = "# family | style | path | scripts | licence | licence file\n\
BIZ UDPGothic | Regular | fonts/biz.ttf | Jpan,Latn | OFL-1.1 | fonts/biz/OFL.txt\n\
Noto Sans CJK SC | Regular | fonts/noto/NotoSansCJKsc-Regular.otf | Hans,Latn | OFL-1.1 | fonts/noto/OFL.txt\n";
assert_eq!(chinese_font_in_manifest(manifest), Some(("fonts/noto/NotoSansCJKsc-Regular.otf", "fonts/noto/OFL.txt")));
assert_eq!(chinese_font_in_manifest("BIZ UDPGothic | Regular | fonts/biz.ttf | Jpan,Latn | OFL-1.1 | fonts/biz/OFL.txt"), None);
}
#[test]