mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-15 03:36:57 -04:00
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:
1 parent
c435d143de
commit
bf35a3c6c5
16 files changed
+267
-43
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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日 星期日`)。
|
||||
|
||||
## 翻译的维护
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user