LR-BEHAV-LOCALIZE: a language table, Simplified Chinese, and per-script font fallback
The localization layer was hardcoded to English and Japanese: an enum with two
variants, an `is_japanese()` gate compiled into the format macro, and one
catalog pair. Adding a language meant editing the menus, the settings row, the
checkmark logic, the format macro and the font stack — each in a different place.
A language is now one entry in `i18n::language_table!`: BCP-47 code, endonym,
ISO 15924 script and catalog. The Language menu, the settings row, the shortcut
sheet and the control channel are all built from that table, and `build.rs`
picks up `locales/<code>-formats.json` by name, so a further language is a table
entry plus two catalogs (docs/localization.md).
Simplified Chinese (`zh-hans`) ships as the second translation: `zh-hans.json`
(1019 entries, key-set and placeholder-identical to `ja.json`) and
`zh-hans-formats.json` (102 entries). Its glyphs come from craft-fonts' Noto
Sans CJK SC, whose `Hans` faces the UI now installs *before* the Japanese ones
for a Chinese reader, so shared Han characters keep the language's forms
(`fonts::cjk_fallback`); the export watermark accepts every CJK face.
`tr_format!` keeps its compile-time check. It formats the English source at the
call site — a runtime template cannot be handed to `format!` — and names those
values so a translation can place them by name (`{n}`) or position (`{_1}`).
Every translation is still checked by `format!`, so a translation with an
unknown placeholder fails the build. The Japanese catalog was rewritten from the
old `{:.0}` plural-discard trick to that by-name form; behaviour is unchanged.
Also: settings persist the BCP-47 code (never the Rust variant name),
`LIGHTCRAFT_LANGUAGE` accepts system-style tags (`zh_CN`, `ja_JP.UTF-8`),
`xtask parity` reads the second command table, and the glyph tests cover every
catalog character plus the interface symbols (`▸`/`▾` turned out to have no
glyph in any installed font and are gone from the Chinese text).
Chinese and Japanese both need `CRAFT_FONTS_DIR` as before; the web build still
embeds only the Japanese face (Noto Sans CJK SC alone would push the wasm past
Cloudflare's 25 MiB per-file cap), documented in docs/localization-zh-hans.md.
Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
This commit is contained in:
parent
5cee0c08d0
commit
22eb4d3515
|
|
@ -334,14 +334,14 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
|
|||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
**Japanese text** needs the shared font repo, an optional build input (official releases always include it):
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
||||
```
|
||||
|
||||
Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
|
|
@ -432,7 +432,7 @@ Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel
|
|||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases)
|
||||
also embed its Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
|
||||
also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
|
|
|
|||
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load |
|
||||
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; accessibility partial; headless UI tests time out under machine load |
|
||||
|
||||
### By kind of user
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
|||
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
|
||||
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
|
||||
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) |
|
||||
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) |
|
||||
|
||||
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
|
||||
|
||||
|
|
@ -192,12 +192,14 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
|||
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
|
||||
tracker rows for camera colour, camera coverage and render fidelity.
|
||||
|
||||
## Japanese interface and text watermarks
|
||||
## Chinese and Japanese interfaces, and text watermarks
|
||||
|
||||
English/Japanese interface language is persisted in UI state. Core menus have Japanese
|
||||
translations; untranslated panels and dialogs retain English. Japanese glyphs (UI: BIZ UDPGothic;
|
||||
watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the
|
||||
optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
|
||||
English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the
|
||||
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
|
||||
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
|
||||
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
|
||||
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
|
||||
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
|
||||
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
|
||||
characters stay upright in top-to-bottom columns, with newlines starting columns to the
|
||||
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
|
|||
out.push(';');
|
||||
}
|
||||
}
|
||||
let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese());
|
||||
let mut s = format!("{};", lightcraft_ui_egui::i18n::uses_cjk_fonts());
|
||||
for (t, items) in bar {
|
||||
s.push_str(t);
|
||||
walk(items, &mut s);
|
||||
|
|
|
|||
|
|
@ -245,14 +245,15 @@ impl Default for Watermark {
|
|||
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
|
||||
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
|
||||
|
||||
/// The watermark faces: Inter first, then the craft-fonts Japanese faces (Mincho first, the
|
||||
/// watermark's serif look; then any other Japanese face). Without craft-fonts that is Inter alone,
|
||||
/// and Japanese characters draw as Inter's missing-glyph box.
|
||||
/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
|
||||
/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is
|
||||
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
|
||||
/// missing-glyph box.
|
||||
fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> {
|
||||
let mut japanese: Vec<_> = crate::fonts::japanese(craft).collect();
|
||||
japanese.sort_by_key(|f| !f.is_mincho());
|
||||
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
|
||||
cjk.sort_by_key(|f| !f.is_mincho());
|
||||
std::iter::once(WATERMARK_FONT)
|
||||
.chain(japanese.into_iter().map(|f| f.bytes))
|
||||
.chain(cjk.into_iter().map(|f| f.bytes))
|
||||
.filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok())
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! Fonts from the optional craft-fonts build input (storytold/craft-fonts).
|
||||
//!
|
||||
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
|
||||
//! manifest (today: the Japanese UI and document fonts); otherwise it is empty and LightCraft uses
|
||||
//! only its own bundled fonts (Inter). Both the egui UI and the export watermark renderer read it
|
||||
//! from here. See craftrules `standards/fonts.md`.
|
||||
//! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is
|
||||
//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export
|
||||
//! watermark renderer read it from here. See craftrules `standards/fonts.md`.
|
||||
|
||||
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
|
||||
pub struct CraftFont {
|
||||
|
|
@ -33,6 +33,43 @@ pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static Cr
|
|||
fonts.iter().filter(|f| f.covers("Jpan"))
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for any CJK script: what any CJK text needs, regardless of language.
|
||||
pub fn cjk(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static CraftFont> {
|
||||
fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script)))
|
||||
}
|
||||
|
||||
/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK
|
||||
/// scripts), which Inter has no glyphs for.
|
||||
pub fn is_cjk_script(script: &str) -> bool {
|
||||
matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang")
|
||||
}
|
||||
|
||||
/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script
|
||||
/// first (for `style` when asked), then every other CJK face. One CJK face list serves every
|
||||
/// language, but Han characters are shared between them: a Chinese reader must get the Chinese
|
||||
/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first.
|
||||
/// Empty without craft-fonts.
|
||||
pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
|
||||
let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect();
|
||||
faces.sort_by_key(|font| {
|
||||
(
|
||||
// The language's own script first. A face dedicated to the script is preferred over a
|
||||
// multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`).
|
||||
!font.covers(script),
|
||||
font.scripts.len() != 1,
|
||||
font.family != "BIZ UDPGothic",
|
||||
font.style != style,
|
||||
font.is_mincho(),
|
||||
)
|
||||
});
|
||||
faces
|
||||
}
|
||||
|
||||
/// Whether the font is for any CJK script.
|
||||
fn covers_cjk(font: &CraftFont) -> bool {
|
||||
font.scripts.iter().any(|script| is_cjk_script(script))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -49,4 +86,21 @@ mod tests {
|
|||
}
|
||||
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
|
||||
}
|
||||
|
||||
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
|
||||
/// text still reaches Inter before any CJK fallback.
|
||||
#[test]
|
||||
fn a_language_gets_its_own_script_first() {
|
||||
static FACES: &[CraftFont] = &[
|
||||
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
|
||||
CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
|
||||
];
|
||||
let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family);
|
||||
assert_eq!(first("Hans"), Some("Noto Sans CJK SC"));
|
||||
assert_eq!(first("Jpan"), Some("BIZ UDPGothic"));
|
||||
// Every CJK face is still offered to every language.
|
||||
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
|
||||
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,89 @@
|
|||
//! Compile translated format strings so Rust checks both languages' placeholders.
|
||||
//! Compile the translated format strings into the `tr_format!` macro.
|
||||
//!
|
||||
//! `locales/<code>-formats.json` maps an English format string to its translation for one language.
|
||||
//! Each catalog becomes a `translated_format` match arm, so every language's placeholders are
|
||||
//! checked by Rust's `format!` — a wrong placeholder is a compile error, not a runtime `{}`.
|
||||
//! Adding a language means adding its file; this script picks it up by name.
|
||||
use std::{collections::BTreeMap, env, fs, path::PathBuf};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=locales/ja-formats.json");
|
||||
let messages: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
|
||||
let mut source = String::from("macro_rules! tr_format {\n");
|
||||
for (english, japanese) in messages {
|
||||
let en = serde_json::to_string(&english)?;
|
||||
let ja = serde_json::to_string(&japanese)?;
|
||||
source.push_str(&format!(
|
||||
"({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n"
|
||||
));
|
||||
}
|
||||
source.push_str("}\npub(crate) use tr_format;\n");
|
||||
fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?;
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
let out = PathBuf::from(env::var("OUT_DIR")?);
|
||||
fs::write(out.join("tr-formats.rs"), generate()?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut catalogs = Vec::new();
|
||||
for entry in fs::read_dir("locales")? {
|
||||
let path = entry?.path();
|
||||
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string();
|
||||
// `en` is the source language: it has no catalog, its formats are the source themselves.
|
||||
let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue };
|
||||
println!("cargo:rerun-if-changed=locales/{name}");
|
||||
let messages: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string(&path)?)?;
|
||||
let variant = code.split(['-', '_']).filter(|part| !part.is_empty()).map(capitalize).collect::<String>();
|
||||
catalogs.push((variant, messages));
|
||||
}
|
||||
catalogs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// `tr_format!` formats the English source at the call site (where `format!` can check it) and
|
||||
// also names every value, which is what lets a translation place them. Positional values are
|
||||
// addressed as `_1`, `_2`, … in call-site order; `macro_rules!` cannot count, so the slot
|
||||
// numbering is spelled out here and grows with `POSITIONAL_SLOTS` if a call site ever needs more.
|
||||
const POSITIONAL_SLOTS: usize = 12;
|
||||
let mut macro_source = String::from(concat!(
|
||||
"/// Translate a format string with runtime values, keeping the English source's placeholders\n",
|
||||
"/// checked by `format!` at the call site. `build.rs` writes this macro from `locales/`.\n",
|
||||
"macro_rules! tr_format {\n",
|
||||
" ($format:literal $(, $($args:tt)*)?) => {\n",
|
||||
" $crate::i18n::tr_format!(@scan () () (_positional) ; $format; $($($args)*)?)\n",
|
||||
" };\n",
|
||||
" (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal; $name:ident = $value:expr $(, $($rest:tt)*)?) => {\n",
|
||||
" $crate::i18n::tr_format!(@scan ($($raw)* $name = $value,) ($($values)* (stringify!($name), format!(\"{}\", $value)),)\n",
|
||||
" $position ; $format; $($($rest)*)?)\n",
|
||||
" };\n",
|
||||
));
|
||||
for slot in 1..=POSITIONAL_SLOTS {
|
||||
macro_source.push_str(&format!(
|
||||
" (@scan ($($raw:tt)*) ($($values:tt)*) (_positional) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{slot}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n (@scan ($($raw:tt)*) ($($values:tt)*) (_{slot}) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{next}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n",
|
||||
next = slot + 1
|
||||
));
|
||||
}
|
||||
macro_source.push_str(concat!(
|
||||
" (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal;) => {\n",
|
||||
" $crate::i18n::format_message($format, format!($format, $($raw)*), &[$($values)*])\n",
|
||||
" };\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"// `macro_rules!` is textual: the module re-export is what lets call sites keep saying\n",
|
||||
"// `crate::i18n::tr_format!`.\n",
|
||||
"pub(crate) use tr_format;\n",
|
||||
"\n",
|
||||
"impl Locale {\n",
|
||||
" /// The translated format string for `source`, or `None` when this language has none.\n",
|
||||
" ///\n",
|
||||
" /// Generated by `build.rs` from `locales/<code>-formats.json`.\n",
|
||||
" pub fn translated_format(self, source: &str) -> Option<&'static str> {\n",
|
||||
" match self {\n",
|
||||
));
|
||||
let mut source = macro_source;
|
||||
for (variant, messages) in &catalogs {
|
||||
source.push_str(&format!(" Locale::{variant} => match source {{\n"));
|
||||
for (english, translated) in messages {
|
||||
source.push_str(&format!(" {} => Some({}),\n", serde_json::to_string(english)?, serde_json::to_string(translated)?));
|
||||
}
|
||||
source.push_str(" _ => None,\n },\n");
|
||||
}
|
||||
source.push_str(" _ => None,\n }\n }\n}\n");
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
/// `zh-hans` → `ZhHans`, the enum variant the `language_table!` entry declares.
|
||||
fn capitalize(part: &str) -> String {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
" · {skipped} skipped (file exists)": "・{skipped}件は同名ファイルのためスキップ",
|
||||
" · {} duplicate{} skipped": "・重複{}枚をスキップ{:.0}",
|
||||
" · {} failed: {}": "・{}件失敗:{}",
|
||||
" · {} moved": "・{}件移動",
|
||||
" · {} not readable": "・{}件は読み込み不可",
|
||||
", {failed} file{} not readable": "、{failed}件を読み込めません{:.0}",
|
||||
" · {} duplicate{} skipped": "・重複{_1}枚をスキップ",
|
||||
" · {} failed: {}": "・{_1}件失敗:{_2}",
|
||||
" · {} moved": "・{_1}件移動",
|
||||
" · {} not readable": "・{_1}件は読み込み不可",
|
||||
", {failed} file{} not readable": "、{failed}件を読み込めません",
|
||||
"Add {local_n} to My Photos": "{local_n}枚をマイフォトに追加",
|
||||
"Add {} Photo{}": "{}枚の写真を追加{:.0}",
|
||||
"Add “{}” to Favorites": "「{}」をお気に入りに追加",
|
||||
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
|
||||
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
|
||||
"Add {} Photo{}": "{_1}枚の写真を追加",
|
||||
"Add “{}” to Favorites": "「{_1}」をお気に入りに追加",
|
||||
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました",
|
||||
"Added {n} photo{} to “{}”": "「{_2}」に{n}枚を追加しました",
|
||||
"All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします",
|
||||
"Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用",
|
||||
"Automatic ({} MB)": "自動({}MB)",
|
||||
"Click {}:1": "クリック時 {}:1",
|
||||
"Automatic ({} MB)": "自動({_1}MB)",
|
||||
"Click {}:1": "クリック時 {_1}:1",
|
||||
"Create {n} Virtual Copies": "{n}枚の仮想コピーを作成",
|
||||
"Decoder: {reason}": "デコーダー:{reason}",
|
||||
"Delete \"{label}\"": "「{label}」を削除",
|
||||
|
|
@ -22,83 +22,83 @@
|
|||
"Delete “{current}”": "「{current}」を削除",
|
||||
"Duplicate \"{label}\"": "「{label}」を複製",
|
||||
"Example: {example}": "例:{example}",
|
||||
"Exported {n} photo{}": "{n}枚を書き出しました{:.0}",
|
||||
"Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}",
|
||||
"Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}",
|
||||
"Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}",
|
||||
"Exporting {} of {total}": "書き出し中:{total}枚中{}枚",
|
||||
"Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}",
|
||||
"Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}",
|
||||
"Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}",
|
||||
"Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}",
|
||||
"Imported {} photo{}": "{}枚を読み込みました{:.0}",
|
||||
"Matches: {}": "一致条件:{}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
|
||||
"Exported {n} photo{}": "{n}枚を書き出しました",
|
||||
"Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました",
|
||||
"Exported {} point curve preset{}": "{_1}件のポイントカーブを書き出しました",
|
||||
"Exported {} preset{}": "{_1}件のプリセットを書き出しました",
|
||||
"Exporting {} of {total}": "書き出し中:{total}枚中{_1}枚",
|
||||
"Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中",
|
||||
"Healed {n} dust spot{}": "{n}か所のほこりを修復しました",
|
||||
"Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました",
|
||||
"Imported {n} preset{}": "{n}件のプリセットを読み込みました",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました。{f}件は読み込めません",
|
||||
"Imported {} photo{}": "{_1}枚を読み込みました",
|
||||
"Matches: {}": "一致条件:{_1}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} %",
|
||||
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
|
||||
"Named ({named_n})": "名前付き({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け(コピーされた設定だけを適用)",
|
||||
"Preset: {name}": "プリセット:{name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({})",
|
||||
"Preview only — {}": "プレビューのみ:{}",
|
||||
"Profile: {}": "プロファイル:{}",
|
||||
"Proof Copy ({})": "校正用コピー({})",
|
||||
"Proof Preview · {}": "校正プレビュー・{}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({_1})",
|
||||
"Preview only — {}": "プレビューのみ:{_1}",
|
||||
"Profile: {}": "プロファイル:{_1}",
|
||||
"Proof Copy ({})": "校正用コピー({_1})",
|
||||
"Proof Preview · {}": "校正プレビュー・{_1}",
|
||||
"Quick Develop · {n} photos": "クイック現像・{n}枚",
|
||||
"Rated {}": "評価:{}",
|
||||
"Rated {}": "評価:{_1}",
|
||||
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
|
||||
"Redo {}": "やり直す:{}",
|
||||
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
|
||||
"Redo {}": "やり直す:{_1}",
|
||||
"Remove “{}” from Favorites": "「{_1}」をお気に入りから削除",
|
||||
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
|
||||
"Rename “{name}”": "「{name}」の名前を変更",
|
||||
"Rendering on the CPU: {why}": "CPUで描画:{why}",
|
||||
"Reset {} Channel": "{}チャンネルをリセット",
|
||||
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
|
||||
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
|
||||
"Showing {n} of {}": "{}枚中{n}枚を表示",
|
||||
"Smart album: {}": "スマートアルバム:{}",
|
||||
"Undo {}": "取り消す:{}",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ",
|
||||
"Version: {}": "バージョン:{}",
|
||||
"Reset {} Channel": "{_1}チャンネルをリセット",
|
||||
"Saved export preset “{}”": "書き出しプリセット「{_1}」を保存しました",
|
||||
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示",
|
||||
"Showing {n} of {}": "{_1}枚中{n}枚を表示",
|
||||
"Smart album: {}": "スマートアルバム:{_1}",
|
||||
"Undo {}": "取り消す:{_1}",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{_1} — Rust製の写真管理・RAW現像アプリ",
|
||||
"Version: {}": "バージョン:{_1}",
|
||||
"none (CPU rendering): {why}": "なし(CPU描画):{why}",
|
||||
"the {n} selected photos": "選択中の{n}枚",
|
||||
"the {} photos in view": "表示中の{}枚",
|
||||
"the {} photos in view": "表示中の{_1}枚",
|
||||
"these {count} photos": "この{count}枚の写真",
|
||||
"{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚{:.0}・{:.1}MB",
|
||||
"{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚・{_1}MB",
|
||||
"{f} folders (and their subfolders)": "{f}個のフォルダとそのサブフォルダ",
|
||||
"{label} Grading": "{label}のカラーグレーディング",
|
||||
"{label} {d:+} on every selected photo": "選択したすべての写真の{label}を{d:+}調整",
|
||||
"{label}, {n} photos": "{label}、{n}枚",
|
||||
"{matching} of {t} photos": "{t}枚中{matching}枚",
|
||||
"{matching} photos": "{matching}枚",
|
||||
"{mode} spot {} of {}": "{mode}:{1}か所中{0}か所",
|
||||
"{mode} spot {} of {}": "{mode}:{_2}か所中{_1}か所",
|
||||
"{msg} ({failed} failed)": "{msg}({failed}件失敗)",
|
||||
"{name} Label": "{name}のラベル",
|
||||
"{name} Off": "{name}を無効にする",
|
||||
"{name} On": "{name}を有効にする",
|
||||
"{name} — double-click to reset it": "{name}:ダブルクリックでリセット",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n}件の変更{:.0}をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます{:.0}",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n}件の変更をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます",
|
||||
"{n} found · {sel} selected": "{n}枚検出・{sel}枚選択",
|
||||
"{n} photos": "{n}枚",
|
||||
"{n} photos in {} are hidden by the filters above": "{}の{n}枚は上のフィルターで非表示になっています",
|
||||
"{n} photo{}": "{n}枚{:.0}",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新",
|
||||
"{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新",
|
||||
"{n} photos in {} are hidden by the filters above": "{_1}の{n}枚は上のフィルターで非表示になっています",
|
||||
"{n} photo{}": "{n}枚",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n}枚が一致・写真の変更に応じて自動更新",
|
||||
"{n} photo{} now · updates automatically as photos change": "現在{n}枚・写真の変更に応じて自動更新",
|
||||
"{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}",
|
||||
"{stage}… {:.0}%": "{stage}… {:.0}%",
|
||||
"{stage}… {:.0}%": "{stage}… %",
|
||||
"{what} merge added": "{what}合成を追加しました",
|
||||
"{what} merge failed: {e}": "{what}合成に失敗:{e}",
|
||||
"{} and higher": "{}以上",
|
||||
"{} files": "{}個のファイル",
|
||||
"{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本:垂直または水平にする線に沿ってドラッグ",
|
||||
"{} photos": "{}枚",
|
||||
"{} photo{} not readable": "{}枚を読み込めません{:.0}",
|
||||
"{} spot(s) on this photo": "この写真に{}か所のスポット",
|
||||
"{} and higher": "{_1}以上",
|
||||
"{} files": "{_1}個のファイル",
|
||||
"{} files and {f} folder{}": "{_1}個のファイルと{f}個のフォルダ",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{_1}本:垂直または水平にする線に沿ってドラッグ",
|
||||
"{} photos": "{_1}枚",
|
||||
"{} photo{} not readable": "{_1}枚を読み込めません",
|
||||
"{} spot(s) on this photo": "この写真に{_1}か所のスポット",
|
||||
"· {dups} already in the library": "・{dups}枚は読み込み済み",
|
||||
"· {n} photo{}": "・{n}枚{:.0}",
|
||||
"· {n} photo{}": "・{n}枚",
|
||||
"… and {more} more": "…ほか{more}件",
|
||||
"Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}",
|
||||
"Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
|
||||
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚"
|
||||
"Import cancelled · {} photo{} added": "読み込みを中止しました・{_1}枚を追加",
|
||||
"Reading photos… {} of {}": "写真を確認中:{_2}枚中{_1}枚",
|
||||
"Adding photos… {} of {}": "写真を追加中:{_2}枚中{_1}枚"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
" · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)",
|
||||
" · {} duplicate{} skipped": " · 已跳过 {_1} 个重复项",
|
||||
" · {} failed: {}": " · {_1} 个失败:{_2}",
|
||||
" · {} moved": " · 已移动 {_1} 个",
|
||||
" · {} not readable": " · {_1} 个无法读取",
|
||||
", {failed} file{} not readable": ",{failed} 个文件无法读取",
|
||||
"Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片",
|
||||
"Add {} Photo{}": "添加 {_1} 张照片",
|
||||
"Add “{}” to Favorites": "将“{_1}”添加到收藏夹",
|
||||
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片",
|
||||
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{_2}”",
|
||||
"All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。",
|
||||
"Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片",
|
||||
"Automatic ({} MB)": "自动({_1} MB)",
|
||||
"Click {}:1": "单击 {_1}:1",
|
||||
"Create {n} Virtual Copies": "创建 {n} 个虚拟副本",
|
||||
"Decoder: {reason}": "解码器:{reason}",
|
||||
"Delete \"{label}\"": "删除 \"{label}\"",
|
||||
"Delete \"{label}\" (and the mask)": "删除 \"{label}\"(及蒙版)",
|
||||
"Delete {n} Photos": "删除 {n} 张照片",
|
||||
"Delete “{current}”": "删除“{current}”",
|
||||
"Duplicate \"{label}\"": "复制 \"{label}\"",
|
||||
"Example: {example}": "示例:{example}",
|
||||
"Exported {n} photo{}": "已导出 {n} 张照片",
|
||||
"Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片",
|
||||
"Exported {} point curve preset{}": "已导出 {_1} 个点曲线预设",
|
||||
"Exported {} preset{}": "已导出 {_1} 个预设",
|
||||
"Exporting {} of {total}": "正在导出 {total} 张中的第 {_1} 张",
|
||||
"Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选",
|
||||
"Healed {n} dust spot{}": "已修复 {n} 处尘点",
|
||||
"Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设",
|
||||
"Imported {n} preset{}": "已导入 {n} 个预设",
|
||||
"Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取",
|
||||
"Imported {} photo{}": "已导入 {_1} 张照片",
|
||||
"Matches: {}": "匹配项:{_1}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} %",
|
||||
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
|
||||
"Named ({named_n})": "已命名({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置",
|
||||
"Preset: {name}": "预设:{name}",
|
||||
"Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({_1})",
|
||||
"Preview only — {}": "仅预览 — {_1}",
|
||||
"Profile: {}": "配置文件:{_1}",
|
||||
"Proof Copy ({})": "校样副本({_1})",
|
||||
"Proof Preview · {}": "校样预览 · {_1}",
|
||||
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
|
||||
"Rated {}": "已评级 {_1}",
|
||||
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
|
||||
"Redo {}": "重做:{_1}",
|
||||
"Remove “{}” from Favorites": "从收藏夹中移除“{_1}”",
|
||||
"Rename {n} Photos…": "重命名 {n} 张照片…",
|
||||
"Rename “{name}”": "重命名“{name}”",
|
||||
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
|
||||
"Reset {} Channel": "重置 {_1} 通道",
|
||||
"Saved export preset “{}”": "已保存导出预设“{_1}”",
|
||||
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置",
|
||||
"Showing {n} of {}": "显示 {_1} 张中的 {n} 张",
|
||||
"Smart album: {}": "智能相册:{_1}",
|
||||
"Undo {}": "撤销 {_1}",
|
||||
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {_1} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。",
|
||||
"Version: {}": "版本:{_1}",
|
||||
"none (CPU rendering): {why}": "无(CPU 渲染):{why}",
|
||||
"the {n} selected photos": "选中的 {n} 张照片",
|
||||
"the {} photos in view": "视图中的 {_1} 张照片",
|
||||
"these {count} photos": "这 {count} 张照片",
|
||||
"{count} smart preview{} · {:.1} MB": "{count} 个智能预览 · {_1} MB",
|
||||
"{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)",
|
||||
"{label} Grading": "{label} 颜色分级",
|
||||
"{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}",
|
||||
"{label}, {n} photos": "{label},{n} 张照片",
|
||||
"{matching} of {t} photos": "{t} 张照片中的 {matching} 张",
|
||||
"{matching} photos": "{matching} 张照片",
|
||||
"{mode} spot {} of {}": "{mode}:第 {_1} 个污点,共 {_2} 个",
|
||||
"{msg} ({failed} failed)": "{msg}({failed} 项失败)",
|
||||
"{name} Label": "{name} 标签",
|
||||
"{name} Off": "已关闭 {name}",
|
||||
"{name} On": "已开启 {name}",
|
||||
"{name} — double-click to reset it": "{name} — 双击可重置",
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。",
|
||||
"{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张",
|
||||
"{n} photos": "{n} 张照片",
|
||||
"{n} photos in {} are hidden by the filters above": "{_1} 中的 {n} 张照片被上方的过滤器隐藏",
|
||||
"{n} photo{}": "{n} 张照片",
|
||||
"{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新",
|
||||
"{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新",
|
||||
"{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}",
|
||||
"{stage}… {:.0}%": "{stage}… %",
|
||||
"{what} merge added": "已添加 {what} 合并",
|
||||
"{what} merge failed: {e}": "{what} 合并失败:{e}",
|
||||
"{} and higher": "{_1} 及以上",
|
||||
"{} files": "{_1} 个文件",
|
||||
"{} files and {f} folder{}": "{_1} 个文件和 {f} 个文件夹",
|
||||
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {_1} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。",
|
||||
"{} photos": "{_1} 张照片",
|
||||
"{} photo{} not readable": "{_1} 张照片无法读取",
|
||||
"{} spot(s) on this photo": "此照片上有 {_1} 处污点",
|
||||
"· {dups} already in the library": "· {dups} 张已在图库中",
|
||||
"· {n} photo{}": "· {n} 张照片",
|
||||
"… and {more} more": "…以及另外 {more} 项",
|
||||
"Import cancelled · {} photo{} added": "导入已取消 · 已添加 {_1} 张照片",
|
||||
"Reading photos… {} of {}": "正在读取照片… 第 {_1} 张,共 {_2} 张",
|
||||
"Adding photos… {} of {}": "正在添加照片… 第 {_1} 张,共 {_2} 张"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -55,7 +55,7 @@ fn wrap(r: Result<Value, String>) -> Outcome {
|
|||
|
||||
pub fn all_commands(app: &LightcraftApp) -> Value {
|
||||
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
for (id, label, sc, menu) in crate::menus::UI_COMMANDS {
|
||||
for (id, label, sc, menu) in crate::menus::ui_commands() {
|
||||
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
|
||||
}
|
||||
Value::Array(v)
|
||||
|
|
|
|||
|
|
@ -1,224 +1,418 @@
|
|||
//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
|
||||
use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
|
||||
//!
|
||||
//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the
|
||||
//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its
|
||||
//! message catalog (`locales/<code>.json`, embedded at build time). Adding a language is that entry
|
||||
//! plus its catalogs — every menu and settings surface is built from the table, so nothing else in
|
||||
//! the UI changes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/ja-formats.rs"));
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
#[serde(rename = "en")]
|
||||
En,
|
||||
#[serde(rename = "ja")]
|
||||
Ja,
|
||||
/// The language table.
|
||||
///
|
||||
/// The source language comes first and alone: it is the text the UI is authored in, which every
|
||||
/// other language translates. `name` is written in the language itself (an endonym, so a user who
|
||||
/// cannot read the current UI language can still find their own).
|
||||
macro_rules! language_table {
|
||||
($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => {
|
||||
/// A UI language.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Locale {
|
||||
/// The language the UI is written in.
|
||||
#[default]
|
||||
$source,
|
||||
$($variant,)*
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
/// Every language, in menu order (the source language first).
|
||||
pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*];
|
||||
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self { Self::$source => $source_code, $(Self::$variant => $code,)* }
|
||||
}
|
||||
|
||||
/// The language's own name, as shown in the Language menus.
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self { Self::$source => "English", $(Self::$variant => $name,)* }
|
||||
}
|
||||
|
||||
/// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…):
|
||||
/// the craft-fonts faces for it are the ones this language needs.
|
||||
pub const fn script(self) -> &'static str {
|
||||
match self { Self::$source => "Latn", $(Self::$variant => $script,)* }
|
||||
}
|
||||
|
||||
/// The language's embedded catalog, parsed once. The source language has none: its
|
||||
/// lookups return the source text itself.
|
||||
fn catalog(self) -> &'static BTreeMap<String, String> {
|
||||
static CATALOGS: OnceLock<BTreeMap<Locale, BTreeMap<String, String>>> = OnceLock::new();
|
||||
static EMPTY: OnceLock<BTreeMap<String, String>> = OnceLock::new();
|
||||
// A malformed catalog logs and comes back empty: a broken translation degrades to
|
||||
// English text, it never takes the app down.
|
||||
let parse = |catalog: Option<&'static str>| match catalog {
|
||||
Some(json) => match serde_json::from_str(json) {
|
||||
Ok(messages) => messages,
|
||||
Err(error) => {
|
||||
log::error!("Invalid message catalog for {}: {error}", self.code());
|
||||
BTreeMap::new()
|
||||
}
|
||||
},
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
CATALOGS
|
||||
.get_or_init(|| {
|
||||
Locale::ALL
|
||||
.iter()
|
||||
.map(|language| {
|
||||
let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None };
|
||||
(*language, parse(catalog))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(&self)
|
||||
.unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new))
|
||||
}
|
||||
|
||||
pub fn parse(code: &str) -> Option<Self> {
|
||||
match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None }
|
||||
}
|
||||
|
||||
/// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a
|
||||
/// shipped language. A region or script the table does not ship falls back to the
|
||||
/// language itself, so a user is never left with no language at all.
|
||||
pub fn parse_tag(tag: &str) -> Option<Self> {
|
||||
let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-");
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) {
|
||||
return Some(*exact);
|
||||
}
|
||||
let mut parts = lower.split('-');
|
||||
let language = parts.next()?;
|
||||
// `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language.
|
||||
let script = parts.next().and_then(|part| match part {
|
||||
"hans" | "cn" | "sg" => Some("Hans"),
|
||||
"hant" | "tw" | "hk" | "mo" => Some("Hant"),
|
||||
_ => None,
|
||||
});
|
||||
Self::ALL
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
let base = candidate.code().split('-').next().unwrap_or_default();
|
||||
base == language && script.is_none_or(|script| candidate.script() == script)
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub fn tr(self, source: &str) -> &str {
|
||||
tr_in(self, source)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "English",
|
||||
Self::Ja => "日本語",
|
||||
}
|
||||
// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded
|
||||
// catalog (`locales/<code>.json`).
|
||||
language_table! {
|
||||
En, "en";
|
||||
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
|
||||
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
|
||||
}
|
||||
|
||||
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
|
||||
// language can be renamed in code without invalidating anyone's settings.
|
||||
impl Serialize for Locale {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.code())
|
||||
}
|
||||
pub fn parse(code: &str) -> Option<Self> {
|
||||
match code {
|
||||
"en" => Some(Self::En),
|
||||
"ja" => Some(Self::Ja),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn tr(self, source: &str) -> &str {
|
||||
if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Locale {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let code = String::deserialize(deserializer)?;
|
||||
Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static LANGUAGE: Cell<Language> = const { Cell::new(Language::En) };
|
||||
static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) };
|
||||
}
|
||||
|
||||
pub fn default_language() -> Language {
|
||||
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
|
||||
/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs.
|
||||
pub fn default_language() -> Locale {
|
||||
std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En)
|
||||
}
|
||||
|
||||
pub fn set_language(language: Language) {
|
||||
LANGUAGE.with(|value| value.set(language));
|
||||
pub fn set_language(language: Locale) {
|
||||
LOCALE.with(|value| value.set(language));
|
||||
}
|
||||
|
||||
pub fn is_japanese() -> bool {
|
||||
LANGUAGE.with(|value| value.get() == Language::Ja)
|
||||
pub fn language() -> Locale {
|
||||
LOCALE.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
fn japanese() -> &'static BTreeMap<String, String> {
|
||||
static MESSAGES: OnceLock<BTreeMap<String, String>> = OnceLock::new();
|
||||
MESSAGES.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| {
|
||||
log::error!("Invalid Japanese message catalog: {error}");
|
||||
BTreeMap::new()
|
||||
})
|
||||
})
|
||||
/// Whether the active language needs the craft-fonts CJK faces (Inter alone has no CJK glyphs):
|
||||
/// the native menu bar uses it to pick a text style.
|
||||
pub fn uses_cjk_fonts() -> bool {
|
||||
language().script() != "Latn"
|
||||
}
|
||||
|
||||
/// Translate a built-in display label, preserving unknown labels verbatim.
|
||||
/// Never call this on editable user text, filenames or command identifiers.
|
||||
pub fn tr(source: &str) -> &str {
|
||||
if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
|
||||
tr_in(language(), source)
|
||||
}
|
||||
|
||||
/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed
|
||||
/// out for as long as the `'static` key it was looked up by.
|
||||
fn tr_in(language: Locale, source: &str) -> &str {
|
||||
if language.catalog().is_empty() {
|
||||
return source;
|
||||
}
|
||||
thread_local! {
|
||||
static VERBATIM: RefCell<BTreeMap<Locale, BTreeMap<String, &'static str>>> = const { RefCell::new(BTreeMap::new()) };
|
||||
}
|
||||
VERBATIM.with_borrow_mut(|cache| {
|
||||
let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language));
|
||||
verbatim.get(source).copied().unwrap_or(source)
|
||||
})
|
||||
}
|
||||
|
||||
/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow.
|
||||
fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
|
||||
language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect()
|
||||
}
|
||||
|
||||
/// Whether the language translates anything at all (the `tr_format!` fast path).
|
||||
pub fn has_translations(language: Locale) -> bool {
|
||||
!language.catalog().is_empty()
|
||||
}
|
||||
|
||||
/// Translate a format string with runtime values, as `tr_format!` calls it.
|
||||
///
|
||||
/// `english` is the source text already formatted with the call site's values, and `values` names
|
||||
/// those values so the translation can place them. A message with no translation, or a translation
|
||||
/// without placeholders, keeps `english` as it is.
|
||||
pub fn format_message(source: &str, english: String, values: &[(&str, String)]) -> String {
|
||||
let Some(translated) = language().translated_format(source) else {
|
||||
return english;
|
||||
};
|
||||
if !translated.contains('{') {
|
||||
return translated.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(translated.len());
|
||||
let mut rest = translated;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let Some(end) = rest[start..].find('}') else { break };
|
||||
let field = &rest[start + 1..start + end];
|
||||
let (name, _) = field.split_once(':').unwrap_or((field, ""));
|
||||
out.push_str(&rest[..start]);
|
||||
// The catalogs spell every value out by name; anything else (a `{}` that only the English
|
||||
// text used, or a name no call site passes) is left exactly as the translator wrote it.
|
||||
match values.iter().find(|(key, _)| *key == name) {
|
||||
Some((_, value)) => out.push_str(value),
|
||||
None => out.push_str(&rest[start..start + end + 1]),
|
||||
}
|
||||
rest = &rest[start + end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
// `tr_format!` (one arm per language) and the translated-format lookup are generated by
|
||||
// `build.rs`; `Locale` is declared above, so the generated `impl Locale` has it in scope.
|
||||
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_is_valid_and_contains_core_workflows() {
|
||||
let messages: BTreeMap<String, String> = serde_json::from_str(include_str!("../locales/ja.json")).unwrap();
|
||||
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
|
||||
assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}");
|
||||
fn every_language_has_a_catalog_covering_the_source() {
|
||||
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
|
||||
for language in Locale::ALL {
|
||||
assert!(!language.name().is_empty(), "{language:?} has a name");
|
||||
assert!(!language.script().is_empty(), "{language:?} has a script");
|
||||
assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code");
|
||||
if *language == Locale::En {
|
||||
continue;
|
||||
}
|
||||
let messages = language.catalog();
|
||||
assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code());
|
||||
for (key, value) in messages {
|
||||
assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalogs of the translations agree: same keys, same format placeholders. A source string
|
||||
/// added to one language but forgotten in another fails here.
|
||||
#[test]
|
||||
fn language_switches_and_unknown_text_survives() {
|
||||
set_language(Language::Ja);
|
||||
assert_eq!(tr("Exposure"), "露出");
|
||||
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
|
||||
assert_eq!(tr("develop.set"), "develop.set");
|
||||
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
|
||||
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
|
||||
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
|
||||
set_language(Language::En);
|
||||
assert_eq!(tr("Exposure"), "Exposure");
|
||||
fn catalogs_agree_on_keys_and_placeholders() {
|
||||
let fields = |text: &str| {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let Some(end) = rest[start..].find('}') else { break };
|
||||
out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string());
|
||||
rest = &rest[start + end + 1..];
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
let translated: Vec<Locale> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect();
|
||||
let Some((first, rest)) = translated.split_first() else { return };
|
||||
for language in rest {
|
||||
let (a, b) = (first.catalog(), language.catalog());
|
||||
for (key, value) in a {
|
||||
let Some(other) = b.get(key) else {
|
||||
panic!("{language:?} is missing a translation for {key:?}");
|
||||
};
|
||||
assert_eq!(fields(key), fields(value), "{first:?}: {key:?} -> {value:?}");
|
||||
assert_eq!(fields(key), fields(other), "{language:?}: {key:?} -> {other:?}");
|
||||
}
|
||||
for key in b.keys() {
|
||||
assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Locale tags are matched leniently, so a system locale reaches a shipped language.
|
||||
#[test]
|
||||
fn locale_tags_normalize() {
|
||||
assert_eq!(Locale::parse_tag("en"), Some(Locale::En));
|
||||
assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En));
|
||||
assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans));
|
||||
assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja));
|
||||
assert_eq!(Locale::parse_tag("de"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() {
|
||||
set_language(Language::Ja);
|
||||
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚");
|
||||
fn switching_language_translates_and_restores() {
|
||||
set_language(Locale::ZhHans);
|
||||
assert_eq!(tr("Exposure"), "曝光");
|
||||
assert_eq!(tr("Settings"), "设置");
|
||||
// Untranslated and data-like text passes through untouched.
|
||||
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
|
||||
assert_eq!(tr("develop.set"), "develop.set");
|
||||
assert_eq!(tr("A string no catalog has"), "A string no catalog has");
|
||||
set_language(Locale::Ja);
|
||||
assert_eq!(tr("Exposure"), "露出");
|
||||
set_language(Locale::En);
|
||||
assert_eq!(tr("Exposure"), "Exposure");
|
||||
}
|
||||
|
||||
/// Format strings carry their values in every language, and a language without a translation
|
||||
/// falls back to the English format rather than dropping the values.
|
||||
#[test]
|
||||
fn formats_render_in_every_language() {
|
||||
// The English source is what `format!` checks at the call site; each translation then places
|
||||
// the same values by name (`{n}`, `{total}`) or by position (`{_1}`, `{_2}`).
|
||||
set_language(Locale::En);
|
||||
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos");
|
||||
set_language(Locale::ZhHans);
|
||||
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片");
|
||||
set_language(Locale::Ja);
|
||||
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました");
|
||||
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました");
|
||||
set_language(Language::En);
|
||||
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos");
|
||||
set_language(Locale::En);
|
||||
// A format no catalog carries keeps its values, and one with no values keeps its text.
|
||||
assert_eq!(tr_format!("{n} uncatalogued {thing}", n = 3, thing = "things"), "3 uncatalogued things");
|
||||
assert_eq!(tr_format!("Activity"), "Activity");
|
||||
}
|
||||
|
||||
/// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation
|
||||
/// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK
|
||||
/// catalogs are skipped (the same degradation the Japanese UI has always had).
|
||||
#[test]
|
||||
fn catalog_characters_are_paintable() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
for language in Locale::ALL {
|
||||
let messages = language.catalog();
|
||||
if messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The faces this language's script needs; a language the craft-fonts input does not
|
||||
// cover (a translation added ahead of its font) is reported, not failed.
|
||||
let mut faces: Vec<&str> =
|
||||
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.covers(language.script())).map(|font| font.family).collect();
|
||||
faces.dedup();
|
||||
if faces.is_empty() {
|
||||
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
|
||||
continue;
|
||||
}
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
|
||||
let font = egui::FontId::new(13.0, family.clone());
|
||||
let mut missing: Vec<char> = messages
|
||||
.values()
|
||||
.flat_map(|message| message.chars())
|
||||
.filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch))
|
||||
.collect();
|
||||
missing.sort_unstable();
|
||||
missing.dedup();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"{} in {family:?} (faces: {faces:?}) lack glyphs: {}",
|
||||
language.code(),
|
||||
missing.iter().collect::<String>()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a
|
||||
/// translation must not carry a symbol no installed font has (egui's defaults cover the rest).
|
||||
#[test]
|
||||
fn interface_symbols_are_paintable() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
// ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07).
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] {
|
||||
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
|
||||
assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The language menu covers every language, and a language's own command selects it.
|
||||
#[test]
|
||||
fn language_commands_cover_every_language() {
|
||||
let commands = [("app.language.english", Locale::En), ("app.language.zh-hans", Locale::ZhHans), ("app.language.japanese", Locale::Ja)];
|
||||
// One command per language, and every command reachable from the menu table.
|
||||
assert_eq!(commands.len(), Locale::ALL.len());
|
||||
for (id, language) in commands {
|
||||
assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}");
|
||||
assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu");
|
||||
}
|
||||
assert_eq!(crate::menus::language_from_command("app.language.klingon"), None);
|
||||
assert_eq!(crate::menus::language_from_command("view.detail"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_round_trip_and_old_settings_remain_readable() {
|
||||
let old: crate::state::UiState = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old.language, Language::En);
|
||||
let settings = crate::state::UiState { language: Language::Ja, ..old };
|
||||
assert_eq!(old.language, Locale::En);
|
||||
// Settings written before the language list grew still load.
|
||||
let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap();
|
||||
assert_eq!(legacy.language, Locale::Ja);
|
||||
let settings = crate::state::UiState { language: Locale::ZhHans, ..old };
|
||||
let saved = serde_json::to_string(&settings).unwrap();
|
||||
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, Language::Ja);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_is_painted_and_both_font_weights_cover_the_catalog() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
app.ui.language = Language::Ja;
|
||||
app.ui.left_panel = true;
|
||||
let mut text = String::new();
|
||||
fn collect(shape: &egui::epaint::Shape, text: &mut String) {
|
||||
match shape {
|
||||
egui::epaint::Shape::Text(shape) => {
|
||||
text.push_str(&shape.galley.job.text);
|
||||
text.push('\n');
|
||||
}
|
||||
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for frame in 0..4 {
|
||||
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
// This assertion inspects shapes without a renderer; discard texture uploads explicitly.
|
||||
out.textures_delta.clear();
|
||||
text.clear();
|
||||
for shape in out.shapes {
|
||||
collect(&shape.shape, &mut text);
|
||||
}
|
||||
}
|
||||
assert!(text.contains("マイフォト"), "{text}");
|
||||
assert!(text.contains("すべての写真"), "{text}");
|
||||
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
|
||||
eprintln!("skipped glyph coverage: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
|
||||
} else {
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
for message in japanese().values() {
|
||||
for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
|
||||
assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Locale affects presentation only: command ids remain the same.
|
||||
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
|
||||
let japanese_ids = ids(&app);
|
||||
set_language(Language::En);
|
||||
assert_eq!(japanese_ids, ids(&app));
|
||||
}
|
||||
/// The UI families, after one frame so the font definitions are loaded.
|
||||
fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions(craft));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
ctx
|
||||
}
|
||||
|
||||
fn ui_families() -> [egui::FontFamily; 3] {
|
||||
[egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace]
|
||||
}
|
||||
|
||||
/// Built with craft-fonts, Japanese renders with real glyphs (no tofu) in every UI family.
|
||||
#[test]
|
||||
fn craft_fonts_render_japanese_in_the_ui() {
|
||||
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
|
||||
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
|
||||
return;
|
||||
}
|
||||
let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS);
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in ui_families() {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
for ch in "日本語の文字".chars() {
|
||||
assert!(fonts.has_glyph(&font, ch), "{ch} in {font:?}");
|
||||
}
|
||||
let galley = fonts.layout_no_wrap("日本語の文字".into(), font.clone(), egui::Color32::WHITE);
|
||||
assert!(galley.size().x > 13.0 * 5.0, "{font:?}: six full-width glyphs, {:?}", galley.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Built without craft-fonts, the UI (in Japanese, too) still installs its fonts and runs;
|
||||
/// Latin text keeps Inter.
|
||||
#[test]
|
||||
fn the_ui_works_without_craft_fonts() {
|
||||
let ctx = fonts_ctx(&[]);
|
||||
ctx.fonts_mut(|fonts| {
|
||||
// (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's
|
||||
// replacement-glyph face, which there is Hack, the face that draws Latin.)
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
|
||||
let font = egui::FontId::new(13.0, family);
|
||||
assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}");
|
||||
}
|
||||
});
|
||||
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
app.ui.language = Language::Ja;
|
||||
for frame in 0..3 {
|
||||
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
set_language(Language::En);
|
||||
assert_eq!(restored.language, Locale::ZhHans);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`.
|
||||
//!
|
||||
//! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands
|
||||
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
|
||||
//! (views, panels, zoom — see [`menus::ui_commands`]) and forwards everything else to the engine.
|
||||
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry
|
||||
//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts,
|
||||
//! (engine commands with menu paths + [`crate::menus::ui_commands`]) with live labels, shortcuts,
|
||||
//! enabled and checked state.
|
||||
//!
|
||||
//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the
|
||||
|
|
@ -270,8 +270,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
|
|||
let u = &app.ui;
|
||||
let panel = |p: RightPanel| Some(u.right == p);
|
||||
match id {
|
||||
"app.language.english" => Some(u.language == crate::i18n::Language::En),
|
||||
"app.language.japanese" => Some(u.language == crate::i18n::Language::Ja),
|
||||
// Every language's command is checked when it is the active one.
|
||||
_ if crate::menus::language_from_command(id).is_some() => Some(crate::menus::language_from_command(id) == Some(u.language)),
|
||||
"develop.autoSync" => Some(app.session.auto_sync),
|
||||
"view.photoCounts" => Some(u.show_counts),
|
||||
"view.secondWindow" => Some(u.second_window),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,31 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
|
|||
/// (id, label, shortcut, menu path)
|
||||
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
|
||||
|
||||
/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language
|
||||
/// added to the table shows up in the menu (and in the control channel's command list) by itself.
|
||||
const LANGUAGE_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
|
||||
("app.language.zh-hans", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
|
||||
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
|
||||
];
|
||||
|
||||
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
|
||||
/// this file, so an id listed in `docs/parity.md` is checked wherever it is declared.
|
||||
pub fn ui_commands() -> impl Iterator<Item = &'static UiCommand> {
|
||||
LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS)
|
||||
}
|
||||
|
||||
/// The language a Language-menu command selects, if the id is one. The engine and the UI both go
|
||||
/// through here, so the menu, the settings row and the control channel agree on the mapping.
|
||||
pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
|
||||
match id {
|
||||
"app.language.english" => Some(crate::i18n::Locale::En),
|
||||
"app.language.zh-hans" => Some(crate::i18n::Locale::ZhHans),
|
||||
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("app.language.english", "English", None, "Edit>Language"),
|
||||
("app.language.japanese", "日本語", None, "Edit>Language"),
|
||||
|
|
@ -206,8 +231,8 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
|||
|
||||
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "app.language.english" | "app.language.japanese") {
|
||||
app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En };
|
||||
if let Some(language) = language_from_command(id) {
|
||||
app.ui.language = language;
|
||||
return Some(Ok(json!(app.ui.language)));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
|
|
@ -1251,8 +1276,7 @@ pub struct MenuEntry {
|
|||
|
||||
/// The flattened menu model (UI commands + engine commands with menu paths).
|
||||
pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
|
||||
let mut v: Vec<MenuEntry> = UI_COMMANDS
|
||||
.iter()
|
||||
let mut v: Vec<MenuEntry> = ui_commands()
|
||||
.filter(|c| !c.3.is_empty())
|
||||
.map(|(id, label, sc, m)| MenuEntry {
|
||||
id: id.to_string(),
|
||||
|
|
|
|||
|
|
@ -733,7 +733,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
Dialog::Shortcuts => {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, label, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
ui.label(crate::i18n::tr(label));
|
||||
ui.label(*sc);
|
||||
|
|
@ -750,8 +750,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
for (sc, id, _) in crate::shortcuts::ALIASES {
|
||||
let label = crate::menus::UI_COMMANDS
|
||||
.iter()
|
||||
let label = crate::menus::ui_commands()
|
||||
.find(|c| c.0 == *id)
|
||||
.map(|c| c.1)
|
||||
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str
|
|||
|
||||
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
row(ui, t, crate::i18n::tr("Language"), |ui| {
|
||||
choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language);
|
||||
let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect();
|
||||
choices(ui, "settingsLanguage", &languages, &mut app.ui.language);
|
||||
crate::i18n::set_language(app.ui.language);
|
||||
});
|
||||
heading(ui, t, crate::i18n::tr("Library"));
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
|
||||
ctx.input(|i| {
|
||||
let mut ui_keys = Vec::new();
|
||||
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some((m, k)) = sc.and_then(parse) {
|
||||
ui_keys.push((m, k));
|
||||
if !native(sc.unwrap_or_default()) && matches(i, m, k) {
|
||||
|
|
@ -286,7 +286,7 @@ mod tests {
|
|||
assert_eq!(parse("Right").unwrap().1, Key::ArrowRight);
|
||||
assert_eq!(parse("\\").unwrap().1, Key::Backslash);
|
||||
// every declared shortcut parses
|
||||
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(sc) = sc {
|
||||
assert!(parse(sc).is_some(), "{id}: {sc}");
|
||||
}
|
||||
|
|
@ -300,7 +300,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn aliases_parse_and_target_existing_commands() {
|
||||
let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect();
|
||||
let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect();
|
||||
for (sc, id, params) in ALIASES {
|
||||
assert!(parse(sc).is_some(), "{id}: {sc}");
|
||||
assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}");
|
||||
|
|
@ -315,7 +315,7 @@ mod tests {
|
|||
#[test]
|
||||
fn no_conflicting_bindings() {
|
||||
let mut ui: Vec<((Modifiers, Key), String)> = Vec::new();
|
||||
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
|
||||
for (id, _, sc, _) in crate::menus::ui_commands() {
|
||||
if let Some(k) = sc.and_then(parse) {
|
||||
ui.push((k, id.to_string()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0;
|
|||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
#[serde(default = "crate::i18n::default_language")]
|
||||
pub language: crate::i18n::Language,
|
||||
pub language: crate::i18n::Locale,
|
||||
/// The Build Previews run last announced (its identity, finished?).
|
||||
#[serde(skip)]
|
||||
pub preview_build_seen: Option<(usize, bool)>,
|
||||
|
|
|
|||
|
|
@ -110,31 +110,35 @@ pub fn install_fonts(ctx: &egui::Context) {
|
|||
ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS));
|
||||
}
|
||||
|
||||
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts Japanese faces as
|
||||
/// the last fallback of every family (BIZ UDPGothic first; Bold first for the semibold family).
|
||||
/// Without craft-fonts (`craft` empty) Japanese text has no glyphs and shows as boxes.
|
||||
/// 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.
|
||||
pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> 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"))));
|
||||
let japanese: Vec<_> = lightcraft_engine::fonts::japanese(craft).collect();
|
||||
for f in &japanese {
|
||||
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
|
||||
}
|
||||
// Craft-fonts faces in preference order for a family drawn in `style`.
|
||||
let fallback = |style: &str| {
|
||||
let mut faces = japanese.clone();
|
||||
faces.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != style, f.family.contains("Mincho")));
|
||||
faces.into_iter().map(craft_font_name).collect::<Vec<_>>()
|
||||
lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style)
|
||||
.into_iter()
|
||||
.map(craft_font_name)
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
let (regular, 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 defaults: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let mut prop = vec!["Inter".to_string()];
|
||||
prop.extend(defaults.iter().cloned());
|
||||
prop.extend(fallback("Regular"));
|
||||
prop.extend(regular);
|
||||
fonts.families.insert(FontFamily::Proportional, prop);
|
||||
let mut semi = vec!["Inter-SemiBold".to_string()];
|
||||
semi.extend(defaults);
|
||||
semi.extend(fallback("Bold"));
|
||||
semi.extend(bold);
|
||||
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular"));
|
||||
fonts
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# LightCraft 日本語表示
|
||||
|
||||
LightCraft は日本語と英語(English)の表示に対応しています。
|
||||
LightCraft は日本語・簡体字中国語・英語(English)の表示に対応しています。
|
||||
言語設定は `ui.json` の `language` に保存します。
|
||||
|
||||
- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。
|
||||
- 「編集 → 言語」または「設定 → 一般 → 言語」で切り替えます。選択は次回起動にも引き継ぎます。
|
||||
- メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。
|
||||
- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを使います。日本語フォントはこのリポジトリではなく
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) にあり、任意のビルド入力
|
||||
|
|
@ -11,13 +11,18 @@ LightCraft は日本語と英語(English)の表示に対応しています
|
|||
指定しない場合も動作しますが、日本語の文字は表示されません。
|
||||
- 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。
|
||||
- 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。
|
||||
- 中国語表示は [`localization-zh-hans.md`](localization-zh-hans.md) を参照してください。
|
||||
- 別の言語の追加方法は [`localization.md`](localization.md) を参照してください。
|
||||
|
||||
## 翻訳の保守
|
||||
|
||||
静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は
|
||||
`crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。
|
||||
可変文言の両言語はビルド時にRustのフォーマット検査を受けます。
|
||||
英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。
|
||||
|
||||
可変文言は、呼び出し側の英語の書式を `format!` がそのまま検査し、その値を翻訳側が
|
||||
名前(`{n}`、`{total}`)または位置(`_1`、`_2` …)で参照します。英語の単複数語尾のように
|
||||
中国語・日本語で不要な値は、**単に参照しないだけ**です(`{:.0}` のような空の引数は不要)。
|
||||
両言語の訳文はビルド時に `format!` の検査を受けるため、存在しない名前を書くとコンパイルエラーになります。
|
||||
`LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。
|
||||
|
||||
表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# LightCraft 简体中文界面
|
||||
|
||||
LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 `ui.json` 的 `language` 字段中。
|
||||
|
||||
- 通过「编辑 → 语言」或「设置 → 一般 → 语言」切换,选择会保留到下次启动。
|
||||
- 已翻译的范围:菜单、照片编辑、蒙版、裁剪、设置、导入、导出,以及主要的进度提示。
|
||||
- 正文字重与半粗体都使用 SIL OFL 的 **Noto Sans CJK SC**(简体中文)与 BIZ UDPGothic(日语)。
|
||||
这些字体不在本仓库,而在 [storytold/craft-fonts](https://github.com/storytold/craft-fonts);
|
||||
使用可选的构建输入 `CRAFT_FONTS_DIR=../craft-fonts` 时(所有正式发布版)会内嵌进来。
|
||||
不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框。
|
||||
- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。
|
||||
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
|
||||
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
|
||||
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
|
||||
原生桌面版不受影响。
|
||||
- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。
|
||||
|
||||
## 翻译的维护
|
||||
|
||||
静态界面文字在 `crates/ui-egui/locales/zh-hans.json`,含运行时数值的文字在
|
||||
`crates/ui-egui/locales/zh-hans-formats.json`。两者都以英文原文为键。
|
||||
|
||||
含数值的文字遵循以下约定:
|
||||
|
||||
- 调用点的英文原文由 `format!` 检查(占位符写错会编译失败),翻译再按名字或位置引用同一个值。
|
||||
- **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。
|
||||
- **按位置**:`_1`、`_2`、`_3` … 对应调用点参数的先后顺序(从 1 开始),用于调整语序,
|
||||
例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{_2}”`。
|
||||
- 英文用来表示单复数的那个参数(`"s"` / `""`)在中文里**不需要写**,直接不引用即可;
|
||||
不需要 `{:.0}` 之类的空占位符。
|
||||
- 译文里的占位符同样会被 `format!` 检查:写了不存在的名字就编译不过。
|
||||
|
||||
`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。
|
||||
|
||||
翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由
|
||||
`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# Localization
|
||||
|
||||
The interface ships in the language it is written in (English) plus every language in the table in
|
||||
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`) and Japanese
|
||||
(`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes
|
||||
are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-ja.md`](localization-ja.md).
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to
|
||||
`locales/<code>.json` and `locales/<code>-formats.json`, then translate the values. Keys are the
|
||||
English source strings and must stay byte-for-byte identical — `cargo test -p lightcraft-ui-egui
|
||||
i18n::tests` fails on a missing, extra or empty entry.
|
||||
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
|
||||
|
||||
```rust
|
||||
language_table! {
|
||||
En, "en";
|
||||
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
|
||||
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
|
||||
}
|
||||
```
|
||||
|
||||
The fields are the BCP-47 code (also the settings-file value), the **endonym** shown in the
|
||||
Language menu, the ISO 15924 script (`Latn`, `Jpan`, `Hans`, `Hant`, `Kore`…), and the embedded
|
||||
catalog. `build.rs` picks up `<code>-formats.json` by name, so the macro and the format lookup
|
||||
follow automatically — nothing else in the UI needs to change. The menus, the settings row, the
|
||||
keyboard-shortcut sheet and the control channel all read the table.
|
||||
3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a
|
||||
language whose script has no face in the build shows boxes (the test says so instead of failing).
|
||||
Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a
|
||||
missing face is added there, not worked around in this repository.
|
||||
4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check).
|
||||
|
||||
## How a message is found
|
||||
|
||||
`tr("Exposure")` looks the English label up in the active language's catalog and returns the source
|
||||
text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are
|
||||
presentation-only: command ids, file names and user-entered metadata are never translated.
|
||||
|
||||
`tr_format!` is generated by `crates/ui-egui/build.rs`. It formats the **English source at the call
|
||||
site**, which is what gives `format!` a literal to check against that call site's own arguments, and
|
||||
it also names those values so a translation can place them:
|
||||
|
||||
```rust
|
||||
tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n)
|
||||
```
|
||||
|
||||
- A translation refers to a value **by name** exactly as the English source spells it (`{n}`), or
|
||||
**by position** as `_1`, `_2`, … in call-site order. Reordering is fine:
|
||||
`"已将 {n} 张照片添加到“{_2}”"`.
|
||||
- The English plural suffix (`"s"` / `""`) is simply not referenced in languages without plurals.
|
||||
- Every translation is placeholder-checked at compile time: an unknown name, or a spec that does not
|
||||
match its value, fails the build rather than printing `{}` at runtime.
|
||||
- A source string with no translation falls back to the English text, values included.
|
||||
|
||||
## Testing and looking at it
|
||||
|
||||
```sh
|
||||
cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage
|
||||
```
|
||||
|
||||
The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then).
|
||||
To look at a language, render it headless:
|
||||
|
||||
```sh
|
||||
LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000
|
||||
```
|
||||
|
||||
or, with the app running (`--control 7980`), run the language command
|
||||
(`engine.execute {"command": "app.language.zh-hans"}`) and take a screenshot.
|
||||
|
||||
## Language codes and settings
|
||||
|
||||
The settings file stores the BCP-47 code, never the Rust variant name, so a language can be renamed
|
||||
in code without invalidating anyone's saved preference. `LIGHTCRAFT_LANGUAGE` accepts what a system
|
||||
locale looks like (`zh_Hans`, `zh-CN`, `en_US`, `ja_JP.UTF-8`) and falls back to the base language
|
||||
when a region has no dedicated entry.
|
||||
|
|
@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) |
|
||||
| **Total** | 388 | 30 | 90 | 37 | 194/200 (97%) | 139/149 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.9% of 159.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -503,7 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
|
||||
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
| 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-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md` | Japanese/English UI with persisted language preference and Japanese regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Technical errors/release notes and other locales remain untranslated. |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.zh-hans`, `cmd:app.language.japanese` | 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-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese and Japanese 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. Technical errors/release notes and date grouping headers remain English, and the web build embeds only the Japanese face. |
|
||||
| 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 | ⬜ | | |
|
||||
|
|
|
|||
|
|
@ -137,12 +137,17 @@ pub fn parse(md: &str) -> Doc {
|
|||
doc
|
||||
}
|
||||
|
||||
/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines).
|
||||
/// Ids in the menu tables (`("view.detail", "Detail", Some("D"), "View"),` lines): the language
|
||||
/// commands live in their own table, which the Language menu builds from the i18n language list.
|
||||
pub fn ui_command_ids(menus_rs: &str) -> Vec<String> {
|
||||
let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() };
|
||||
let body = &menus_rs[start..];
|
||||
let body = &body[..body.find("];").unwrap_or(body.len())];
|
||||
body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect()
|
||||
let mut ids = Vec::new();
|
||||
for table in ["pub const UI_COMMANDS", "pub const LANGUAGE_COMMANDS"] {
|
||||
let Some(start) = menus_rs.find(table) else { continue };
|
||||
let body = &menus_rs[start..];
|
||||
let body = &body[..body.find("];").unwrap_or(body.len())];
|
||||
ids.extend(body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn known(set: &BTreeSet<String>, id: &str) -> bool {
|
||||
|
|
|
|||
Loading…
Reference in New Issue