diff --git a/NOTICE b/NOTICE index 3f4f4afa..a95eb76a 100644 --- a/NOTICE +++ b/NOTICE @@ -14,3 +14,7 @@ list (author, source, license) is in assets/ATTRIBUTION.md. Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. Showcase photographs by Ansel Adams, Dorothea Lange and NASA (docs/images/) — public domain. See assets/ATTRIBUTION.md. + +BIZ UDMincho: Copyright 2022 The BIZ UDMincho Project Authors +(https://github.com/googlefonts/morisawa-biz-ud-mincho). SIL Open Font License 1.1; +see assets/fonts/OFL-BIZUDMincho.txt. diff --git a/ROADMAP.md b/ROADMAP.md index be749ff8..54c86e81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -189,3 +189,13 @@ own chart-based calibration (M11.4). Adobe matrices are never used. Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU 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 + +English/Japanese interface language is persisted in UI state. Core menus have Japanese +translations; untranslated panels and dialogs retain English. BIZ UDMincho is bundled +under OFL for Japanese glyph coverage without system fonts. 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 +OpenType shaping. The same coverage renderer serves 8/16/32-bit exports. diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index f6b7dc06..c522d756 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -211,7 +211,7 @@ impl NativeMenu { // the application menu let app_menu = Submenu::new("LightCraft", true); let about = MenuItem::with_id("app.about", "About LightCraft", true, None); - let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY)); + let settings = MenuItem::with_id(SETTINGS, app.ui.language.tr("Settings…"), true, accelerator(SETTINGS_KEY)); let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); let _ = app_menu.append_items(&[ &about, @@ -230,7 +230,7 @@ impl NativeMenu { for (title, nodes) in &bar { let sub = Submenu::new(title, true); - if title == "Window" { + if title == app.ui.language.tr("Window") { let _ = sub.append_items(&[ &PredefinedMenuItem::minimize(None), &PredefinedMenuItem::maximize(Some("Zoom")), @@ -245,15 +245,15 @@ impl NativeMenu { .collect(); let nodes = tidy_separators(nodes); self.append_nodes(&sub, &nodes); - if title == "File" { + if title == app.ui.language.tr("File") { // ⌘W, the system's own item let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::close_window(None)]); } - if title == "Window" { + if title == app.ui.language.tr("Window") { let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); sub.set_as_windows_menu_for_nsapp(); } - if title == "Help" { + if title == app.ui.language.tr("Help") { sub.set_as_help_menu_for_nsapp(); } let _ = self.menu.append(&sub); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index c439adc8..bc72d5e6 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -9,6 +9,8 @@ covers a whole directory), or if a licence file referenced in the Licence column | Path | Asset | Creator | Source | Licence | Added | Modifications | |---|---|---|---|---|---|---| | `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none | +| `assets/fonts/BIZUDMincho-Regular.ttf` | BIZ UDMincho Regular | The BIZ UDMincho Project Authors (Morisawa Inc., design TypeBank Co., Ltd.) | https://github.com/google/fonts/tree/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho | OFL-1.1 (`assets/fonts/OFL-BIZUDMincho.txt`) | 2026-10-05 | none | +| `assets/fonts/OFL-BIZUDMincho.txt` | BIZ UDMincho licence | The BIZ UDMincho Project Authors | https://github.com/google/fonts/blob/63833b7d10bb1f86a8f0b91cba2d3ae1f68d1aa3/ofl/bizudmincho/OFL.txt | OFL-1.1 | 2026-10-05 | none | | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/fonts/BIZUDMincho-Regular.ttf b/assets/fonts/BIZUDMincho-Regular.ttf new file mode 100644 index 00000000..b5a3f4cc Binary files /dev/null and b/assets/fonts/BIZUDMincho-Regular.ttf differ diff --git a/assets/fonts/OFL-BIZUDMincho.txt b/assets/fonts/OFL-BIZUDMincho.txt new file mode 100644 index 00000000..1664f9f6 --- /dev/null +++ b/assets/fonts/OFL-BIZUDMincho.txt @@ -0,0 +1,93 @@ +Copyright 2022 The BIZ UDMincho Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 70f89a7a..6ad24fca 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -202,6 +202,8 @@ pub enum Anchor { #[serde(default, rename_all = "camelCase")] pub struct Watermark { pub text: String, + /// Basic Japanese vertical lettering: upright glyphs in columns from right to left. + pub vertical: bool, /// Text height as a fraction of the image's short edge. pub size: f32, /// 0..1. @@ -226,6 +228,7 @@ impl Default for Watermark { fn default() -> Self { Self { text: String::new(), + vertical: false, size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, @@ -241,6 +244,9 @@ impl Default for Watermark { /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); +/// BIZ UDMincho (OFL), Japanese fallback on both native and web. +/// Bundled OFL Japanese font bytes, shared by watermarks and UI glyph fallback. +pub static WATERMARK_JAPANESE_FONT: &[u8] = include_bytes!("../../../assets/fonts/BIZUDMincho-Regular.ttf"); /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { @@ -288,23 +294,67 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: return; } let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return }; + let Ok(japanese) = FontRef::try_from_slice(WATERMARK_JAPANESE_FONT) else { return }; + let fonts = [font, japanese]; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); - let sf = font.as_scaled(PxScale::from(px)); - // Lay out one line. let mut glyphs = Vec::new(); let mut x = 0.0f32; + let mut y = 0.0f32; + let mut tw = 0.0f32; let mut prev = None; - for ch in text.chars() { - let id = sf.glyph_id(ch); - if let Some(p) = prev { - x += sf.kern(p, id); + let columns = text.split('\n').count(); + let mut column = 0usize; + for original in text.chars() { + if original == '\n' { + if wm.vertical { + column = column.saturating_add(1); + tw = tw.max(y); + y = 0.0; + } else { + tw = tw.max(x); + x = 0.0; + y += px; + } + prev = None; + continue; + } + let vertical_form = match original { + '、' => '︑', + '。' => '︒', + '(' => '︵', + ')' => '︶', + '「' => '﹁', + '」' => '﹂', + '『' => '﹃', + '』' => '﹄', + '【' => '︻', + '】' => '︼', + '…' => '︙', + other => other, + }; + let ch = if wm.vertical && fonts[1].glyph_id(vertical_form).0 != 0 { vertical_form } else { original }; + let face = usize::from(fonts[0].glyph_id(ch).0 == 0); + let sf = fonts[face].as_scaled(PxScale::from(px)); + let id = sf.glyph_id(ch); + if wm.vertical { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); + let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); + glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); + y += px; + } else { + if let Some((previous_face, previous)) = prev + && previous_face == face + { + x += sf.kern(previous, id); + } + glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); + x += sf.h_advance(id); + prev = Some((face, id)); } - glyphs.push(id.with_scale_and_position(px, point(x, sf.ascent()))); - x += sf.h_advance(id); - prev = Some(id); } - let (tw, th) = (x, sf.ascent() - sf.descent()); + let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; let inset = wm.inset.clamp(0.0, 0.4) * short; let (w, h) = (width as f32, height as f32); use Anchor::*; @@ -328,10 +378,17 @@ fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { - for g in &glyphs { - if let Some(o) = font.outline_glyph(g.clone()) { + for (face, g, rotation) in &glyphs { + if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { let b = o.px_bounds(); - o.draw(|gx, gy, cov| blend((ox + off + b.min.x) as i32 + gx as i32, (oy + off + b.min.y) as i32 + gy as i32, cov, col, a * alpha)); + o.draw(|gx, gy, cov| { + let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); + let (sx, sy) = match rotation { + Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), + None => (sx, sy), + }; + blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha); + }); } } } @@ -1227,6 +1284,44 @@ mod tests { assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } + #[test] + fn japanese_watermarks_support_vertical_columns_and_legacy_defaults() { + use ab_glyph::Font; + let font = ab_glyph::FontRef::try_from_slice(WATERMARK_JAPANESE_FONT).unwrap(); + for c in "日本語".chars() { + assert_ne!(font.glyph_id(c).0, 0); + } + let old = ExportOptions::from_json(&json!({"watermark": {"text": "日本語"}})); + assert!(!old.watermark.unwrap().vertical); + let options = + ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); + let wm = options.watermark.unwrap(); + assert!(wm.vertical); + let round: Watermark = serde_json::from_value(serde_json::to_value(&wm).unwrap()).unwrap(); + assert!(round.vertical); + let mut img = Rgba8::new(400, 300); + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 64).collect(); + assert!(lit.len() > 100); + let w = lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap(); + let h = lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap(); + assert!(h > w * 2, "vertical Japanese must extend down the column"); + let two = Watermark { text: "日\n本".into(), anchor: Anchor::TopLeft, inset: 0.0, ..wm }; + img.data.fill([0, 0, 0, 255]); + draw_watermark(&mut img, &two); + for (text, dx) in [("本", 0), ("日", 30)] { + let mut single = Rgba8::new(400, 300); + single.data.fill([0, 0, 0, 255]); + draw_watermark(&mut single, &Watermark { text: text.into(), ..two.clone() }); + for y in 0..30 { + for x in 0..30 { + assert_eq!(img.get(x + dx, y), single.get(x, y), "newlines move columns left"); + } + } + } + } + #[test] fn graphic_watermark_is_placed_scaled_and_blended() { let dir = std::env::temp_dir().join(format!("lc-wm-logo-{}", std::process::id())); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7e2e212b..e7c1da94 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -205,7 +205,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, - "watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} + "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false), size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} }), &[], ), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 00000000..36e004c8 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,154 @@ +//! Interface translations. Command ids, document text and file names remain stable. +//! Untranslated labels fall back to English so coverage can grow incrementally. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + Ja, +} + +impl Language { + pub const ALL: [Self; 2] = [Self::En, Self::Ja]; + + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "日本語", + } + } + + pub fn parse(code: &str) -> Option { + match code { + "en" => Some(Self::En), + "ja" => Some(Self::Ja), + _ => None, + } + } + + pub fn tr(self, text: &str) -> &str { + if self == Self::Ja + && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) + { + return japanese; + } + text + } +} + +const JAPANESE: &[(&str, &str)] = &[ + ("Photo", "写真"), + ("Import Photos…", "写真を読み込み…"), + ("Detail", "詳細"), + ("Photo Grid", "写真グリッド"), + ("Square Grid", "正方形グリッド"), + ("Object", "オブジェクト"), + ("Effect", "効果"), + ("Settings…", "環境設定…"), + ("Image", "画像"), + ("Layer", "レイヤー"), + ("Type", "書式"), + ("Select", "選択"), + ("Filter", "フィルター"), + ("Window", "ウィンドウ"), + ("Language", "表示言語"), + ("Save As…", "別名で保存…"), + ("Exit", "終了"), + ("New…", "新規…"), + ("New", "新規"), + ("Horizontal", "横書き"), + ("Vertical", "縦書き"), + ("Orientation", "組み方向"), + ("Layers", "レイヤー"), + ("History", "履歴"), + ("Properties", "プロパティ"), + ("Color", "カラー"), + ("Brush Settings", "ブラシ設定"), + ("Tools", "ツール"), + ("Options", "オプション"), + ("Zoom In", "ズームイン"), + ("Zoom Out", "ズームアウト"), + ("Fit on Screen", "画面に合わせる"), + ("Copy", "コピー"), + ("Cut", "切り取り"), + ("Paste", "貼り付け"), + ("Select All", "すべて選択"), + ("Deselect", "選択を解除"), + ("Export", "書き出し"), + ("Export As…", "形式を指定して書き出し…"), + ("Search…", "検索…"), + ("Theme", "テーマ"), + ("Menu", "メニュー"), + ("File", "ファイル"), + ("Edit", "編集"), + ("Pages", "ページ"), + ("View", "表示"), + ("Help", "ヘルプ"), + ("Preferences", "環境設定"), + ("Preferences…", "環境設定…"), + ("Interface language", "表示言語"), + ("Open…", "開く…"), + ("New blank PDF", "空白の PDF を作成"), + ("Create PDF from file…", "ファイルから PDF を作成…"), + ("Create PDF from images…", "画像から PDF を作成…"), + ("Create PDF from clipboard", "クリップボードから PDF を作成"), + ("Combine files…", "ファイルを結合…"), + ("Save", "保存"), + ("Save as…", "別名で保存…"), + ("Close file", "ファイルを閉じる"), + ("Close all", "すべて閉じる"), + ("Revert", "保存済みの状態に戻す"), + ("Print…", "印刷…"), + ("Document properties…", "文書のプロパティ…"), + ("Undo", "取り消し"), + ("Redo", "やり直し"), + ("Find…", "検索…"), + ("Advanced search…", "高度な検索…"), + ("Copy pages", "ページをコピー"), + ("Cut pages", "ページを切り取り"), + ("Paste pages", "ページを貼り付け"), + ("Fit visible", "表示範囲に合わせる"), + ("Marquee zoom", "範囲指定ズーム"), + ("Take a snapshot", "スナップショットを作成"), + ("Full screen mode", "全画面表示"), + ("Read mode", "閲覧モード"), + ("Switch light / dark theme", "明るい/暗いテーマを切り替え"), + ("Comments panel", "コメントパネル"), + ("Form fields panel", "フォームフィールドパネル"), + ("Clear form", "フォームをクリア"), + ("Find tools and commands…", "ツールとコマンドを検索…"), + ("Zoom", "ズーム"), + ("Actual size", "実際のサイズ"), + ("Zoom to page level", "ページ全体を表示"), + ("Fit to width", "幅に合わせる"), + ("Display theme", "表示テーマ"), + ("Side panels", "サイドパネル"), + ("OK", "OK"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_setting_persists() { + let state = crate::state::UiState { language: Language::Ja, ..Default::default() }; + let saved = serde_json::to_string(&state).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + assert_eq!(crate::state::UiState::default().language, Language::En); + } + + #[test] + fn translations_are_unique_and_preserve_unknown_text() { + for (i, (en, ja)) in JAPANESE.iter().enumerate() { + assert!(!ja.is_empty()); + assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); + assert_eq!(Language::En.tr(en), *en); + } + assert_eq!(Language::Ja.tr("File"), "ファイル"); + assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); + assert_eq!(Language::parse("xx"), None); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8624f0a0..ec4cbc3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod control; pub mod export_task; pub mod headless; +pub mod i18n; pub mod icons; pub mod import; pub mod links; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 4c4d2f73..71571d32 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -250,6 +250,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { 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), "develop.autoSync" => Some(app.session.auto_sync), "view.photoCounts" => Some(u.show_counts), "view.secondWindow" => Some(u.second_window), @@ -593,6 +595,22 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { } bar.push((title.to_string(), tidy(items))); } + fn translate(nodes: &mut [MenuNode], language: crate::i18n::Language) { + for node in nodes { + match node { + MenuNode::Submenu { label, children } => { + *label = language.tr(label).to_string(); + translate(children, language); + } + MenuNode::Item { label, .. } => *label = language.tr(label).to_string(), + MenuNode::Separator => {} + } + } + } + for (title, nodes) in &mut bar { + *title = app.ui.language.tr(title).to_string(); + translate(nodes, app.ui.language); + } bar } @@ -646,9 +664,9 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String { const TITLE_GAP: f32 = 24.0; /// Width of the in-window menu bar's titles. -pub fn bar_width(ui: &egui::Ui) -> f32 { +pub fn bar_width(ui: &egui::Ui, language: crate::i18n::Language) -> f32 { let t = crate::theme::Tokens::get(ui.ctx()); - MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::() + MENUS.iter().map(|m| ui.painter().layout_no_wrap(language.tr(m).to_string(), t.font(13.0), t.text).size().x + TITLE_GAP).sum::() } /// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single @@ -666,14 +684,14 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 if total <= max_width { let saved = ui.spacing().item_spacing.x; ui.spacing_mut().item_spacing.x = TITLE_GAP; - for (title, items) in &bar { + for (index, (title, items)) in bar.iter().enumerate() { let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false)); - crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect); + crate::widgets::register(ui.ctx(), format!("menu:{}", MENUS.get(index).copied().unwrap_or(title.as_str())), r.rect); egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked)); } ui.spacing_mut().item_spacing.x = saved; } else { - let r = ui.add(egui::Button::new(egui::RichText::new("Menu").font(font.clone()).color(t.text_label)).frame(false)); + let r = ui.add(egui::Button::new(egui::RichText::new(app.ui.language.tr("Menu")).font(font.clone()).color(t.text_label)).frame(false)); crate::widgets::register(ui.ctx(), "menu:all", r.rect); egui::Popup::menu(&r).show(|ui| { for (title, items) in &bar { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5f2ed2e0..795c7383 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -11,6 +11,8 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); pub const UI_COMMANDS: &[UiCommand] = &[ + ("app.language.english", "English", None, "Edit>Language"), + ("app.language.japanese", "日本語", None, "Edit>Language"), ("view.photoGrid", "Photo Grid", None, "View"), ("view.squareGrid", "Square Grid", None, "View"), // G: Photo Grid ↔ Square Grid (from other views: the photo grid) @@ -202,6 +204,10 @@ 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> { + 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 }; + return Some(Ok(json!(app.ui.language))); + } let ctx = egui::Context::default(); let r: Result = match id { "view.photoGrid" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 71fa5644..da1b386d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -551,8 +551,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { wm.image_width = (width / 100.0) as f32; } } else { + choices(ui, app.ui.language.tr("Orientation"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal")), (true, app.ui.language.tr("Vertical"))], &mut wm.vertical); field(ui, "Text", |ui| { - ui.add(egui::TextEdit::singleline(&mut wm.text).hint_text("© Your Name").desired_width(f32::INFINITY)) + ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text("© Your Name").desired_width(f32::INFINITY)) }); } choices( diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 8142b9ec..e1aaacf7 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -19,7 +19,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field - let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; + let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui, app.ui.language) + 24.0; sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); } let search_left = full.center().x - sw / 2.0; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 59c8087e..c585c6a7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -200,6 +200,8 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + #[serde(default)] + pub language: crate::i18n::Language, /// The Build Previews run last announced (its identity, finished?). #[serde(skip)] pub preview_build_seen: Option<(usize, bool)>, @@ -508,6 +510,7 @@ pub enum Dialog { impl Default for UiState { fn default() -> Self { UiState { + language: crate::i18n::Language::default(), preview_build_seen: None, unsaved_seen: false, luminance_map_restore: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 65e9c3ef..91f044f9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -117,6 +117,10 @@ pub fn install_fonts(ctx: &egui::Context) { let mut semi = vec!["Inter-SemiBold".to_string()]; semi.extend(fallback); fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi); + fonts.font_data.insert("japanese".into(), Arc::new(FontData::from_static(lightcraft_engine::export::WATERMARK_JAPANESE_FONT))); + for stack in fonts.families.values_mut() { + stack.push("japanese".into()); + } ctx.set_fonts(fonts); } diff --git a/docs/parity.md b/docs/parity.md index b8f0e30c..2f24d599 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -793,3 +793,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | | KEYC-MODULE-OUTPUT | Book / slideshow / print / map / web keys | P2 | ⬜ | | modules not implemented | | KEYC-HELP | Classic help keys (⌘/, F1) | P2 | 🟡 | `cmd:app.shortcuts` | ⌘/ only | + +### Japanese text watermarks + +`app.export` watermark objects accept `vertical` (boolean, defaults to false). The export +dialog offers Horizontal/Vertical and multiline text. Japanese glyphs fall back to bundled +BIZ UDMincho. Vertical lettering uses upright em cells and right-to-left newline columns; +Latin stays upright. Advanced Japanese composition remains open. Regression coverage: +`export::tests::japanese_watermarks_support_vertical_columns_and_legacy_defaults`.