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 ec11897f..34ef427f 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/main.rs b/apps/lightcraft/src/main.rs index b118af96..e835917f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -85,7 +85,18 @@ fn load_prefs() -> Option { return None; } let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?; - serde_json::from_slice::(&bytes).ok().map(UiState::sanitized) + decode_saved_ui(&bytes) +} + +fn decode_saved_ui(bytes: &[u8]) -> Option { + let mut saved: serde_json::Value = serde_json::from_slice(bytes).ok()?; + if saved.get("language").is_none() + && let Some(language) = saved.pointer("/settings/language").cloned() + && let Some(object) = saved.as_object_mut() + { + object.insert("language".into(), language); + } + serde_json::from_value::(saved).ok().map(UiState::sanitized) } fn save_prefs(app: &LightcraftApp) { @@ -345,12 +356,12 @@ fn main() -> eframe::Result { let session = open_session(in_memory, library_dir, seed_demo && files.is_empty()); let mut app = LightcraftApp::new(session, services()); if cfg!(feature = "japanese-local") { - app.ui.settings.language = lightcraft_ui_egui::i18n::Language::Japanese; + app.ui.language = lightcraft_ui_egui::i18n::Language::Ja; } if let Some(ui) = prefs { app.ui = ui; } - lightcraft_ui_egui::i18n::set_language(app.ui.settings.language); + lightcraft_ui_egui::i18n::set_language(app.ui.language); app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); @@ -372,3 +383,16 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod preference_tests { + use super::decode_saved_ui; + use lightcraft_ui_egui::i18n::Language; + #[test] + fn language_migrates_and_upstream_choice_wins() { + assert_eq!(decode_saved_ui(br#"{"settings":{"language":"ja"}}"#).unwrap().language, Language::Ja); + assert_eq!(decode_saved_ui(br#"{"language":"en","settings":{"language":"ja"}}"#).unwrap().language, Language::En); + assert_eq!(decode_saved_ui(br#"{"language":"ja"}"#).unwrap().language, Language::Ja); + assert!(decode_saved_ui(b"not JSON").is_none()); + } +} diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 82ff0df5..8af250a5 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -342,7 +342,7 @@ impl NativeMenu { /// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus /// accelerators with the app state. pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { - lightcraft_ui_egui::i18n::set_language(app.ui.settings.language); + lightcraft_ui_egui::i18n::set_language(app.ui.language); while let Ok(key) = self.rx.try_recv() { if key == QUIT { ctx.send_viewport_cmd(egui::ViewportCommand::Close); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 29c3ee42..3d2133c5 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/meta/src/datetime.rs b/crates/meta/src/datetime.rs index ed77ee9e..271aa67d 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -71,7 +71,7 @@ impl DateTime { }; let (h, m) = match rest.split_once(':') { Some((h, m)) => (num(h)?, num(m)?), - None if rest.len() == 4 => (num(&rest[..2])?, num(&rest[2..])?), + None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?), None => (num(rest)?, 0), }; if h > 18 || m > 59 { @@ -180,6 +180,17 @@ mod tests { assert_eq!(DateTime::parse_offset("+99:00"), None); } + #[test] + fn unicode_offsets_are_rejected_without_panicking() { + for offset in ["+1€", "-1€", "+€1", "-€1"] { + assert_eq!(DateTime::parse_offset(offset), None); + let date = format!("2024-06-01T13:45:59{offset}"); + let metadata = crate::Metadata { capture_time: DateTime::parse_iso("2024-06-01T13:45:59Z"), ..Default::default() }; + let xmp = crate::write_xmp(&metadata, None).replace("2024-06-01T13:45:59+00:00", &date); + assert!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time.is_none()); + } + } + #[test] fn iso_roundtrip() { for s in ["2024-06-01T13:45:59", "2024-06-01T13:45:59.250+02:00", "1999-12-31T23:59:00-05:30", "2024-02-29T00:00:00Z"] { diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 6c626e8f..6878d37d 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -948,5 +948,64 @@ "memory only": "メモリ上のみ", "none": "なし", "not available here": "ここでは利用できません", - "of the following rules:": "以下の条件に一致:" + "of the following rules:": "以下の条件に一致:", + "Effect": "効果", + "Image": "画像", + "Layer": "レイヤー", + "Type": "書式", + "Save As…": "別名で保存…", + "Exit": "終了", + "New…": "新規…", + "New": "新規", + "Orientation": "組み方向", + "Layers": "レイヤー", + "Properties": "プロパティ", + "Brush Settings": "ブラシ設定", + "Options": "オプション", + "Fit on Screen": "画面に合わせる", + "Cut": "切り取り", + "Paste": "貼り付け", + "Deselect": "選択を解除", + "Export As…": "形式を指定して書き出し…", + "Search…": "検索…", + "Theme": "テーマ", + "Pages": "ページ", + "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 as…": "別名で保存…", + "Close file": "ファイルを閉じる", + "Close all": "すべて閉じる", + "Revert": "保存済みの状態に戻す", + "Print…": "印刷…", + "Document properties…": "文書のプロパティ…", + "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…": "ツールとコマンドを検索…", + "Actual size": "実際のサイズ", + "Zoom to page level": "ページ全体を表示", + "Fit to width": "幅に合わせる", + "Display theme": "表示テーマ", + "Side panels": "サイドパネル", + "Text direction": "文字の方向", + "Horizontal text": "横書き", + "Vertical text": "縦書き" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 386e445b..92355c30 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -9,17 +9,37 @@ include!(concat!(env!("OUT_DIR"), "/ja-formats.rs")); pub enum Language { #[default] #[serde(rename = "en")] - English, + En, #[serde(rename = "ja")] - Japanese, + 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, source: &str) -> &str { + if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } + } } thread_local! { - static LANGUAGE: Cell = const { Cell::new(Language::English) }; + static LANGUAGE: Cell = const { Cell::new(Language::En) }; } pub fn default_language() -> Language { - if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Japanese } else { Language::English } + if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En } } pub fn set_language(language: Language) { @@ -27,7 +47,7 @@ pub fn set_language(language: Language) { } pub fn is_japanese() -> bool { - LANGUAGE.with(|value| value.get() == Language::Japanese) + LANGUAGE.with(|value| value.get() == Language::Ja) } fn japanese() -> &'static BTreeMap { @@ -60,34 +80,34 @@ mod tests { #[test] fn language_switches_and_unknown_text_survives() { - set_language(Language::Japanese); + 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::English); + set_language(Language::En); assert_eq!(tr("Exposure"), "Exposure"); } #[test] fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() { - set_language(Language::Japanese); + set_language(Language::Ja); assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); - set_language(Language::English); + set_language(Language::En); assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos"); } #[test] fn preferences_round_trip_and_old_settings_remain_readable() { - let old: crate::state::AppSettings = serde_json::from_str("{}").unwrap(); - assert_eq!(old.language, Language::English); - let settings = crate::state::AppSettings { language: Language::Japanese, ..old }; + 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 }; let saved = serde_json::to_string(&settings).unwrap(); - let restored: crate::state::AppSettings = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::Japanese); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); } #[test] @@ -95,7 +115,7 @@ mod tests { 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.settings.language = Language::Japanese; + app.ui.language = Language::Ja; app.ui.left_panel = true; let mut text = String::new(); fn collect(shape: &egui::epaint::Shape, text: &mut String) { @@ -136,7 +156,7 @@ mod tests { // 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::>(); let japanese_ids = ids(&app); - set_language(Language::English); + set_language(Language::En); assert_eq!(japanese_ids, ids(&app)); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ef159ef0..ae7050bc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -439,7 +439,7 @@ impl LightcraftApp { /// Per-frame logic before layout (control channel, renders, shortcuts, drops). pub fn logic(&mut self, ctx: &egui::Context) { - i18n::set_language(self.ui.settings.language); + i18n::set_language(self.ui.language); let t0 = now_ms(); self.logic_inner(ctx); self.perf.logic_ms = now_ms() - t0; @@ -587,7 +587,7 @@ impl LightcraftApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { - i18n::set_language(self.ui.settings.language); + i18n::set_language(self.ui.language); let ctx = ui.ctx().clone(); if !self.fonts_ready { ctx.request_repaint(); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 32c61618..0e02c857 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -259,6 +259,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), @@ -668,7 +670,7 @@ const TITLE_GAP: f32 = 24.0; /// Width of the in-window menu bar's titles. pub fn bar_width(ui: &egui::Ui) -> 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(crate::i18n::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 @@ -677,8 +679,10 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 let t = crate::theme::Tokens::get(ui.ctx()); let bar = menu_bar(app); let font = t.font(13.0); - let widths: Vec = - bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + TITLE_GAP).collect(); + let widths: Vec = bar + .iter() + .map(|(title, _)| ui.painter().layout_no_wrap(crate::i18n::tr(title).to_string(), font.clone(), t.text).size().x + TITLE_GAP) + .collect(); let total: f32 = widths.iter().sum(); let mut clicked: Option<(String, Value)> = None; let start = ui.cursor().left(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 36b05f2e..50abfed3 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 2af0ca1d..ea1bfa13 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("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &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/settings.rs b/crates/ui-egui/src/panels/settings.rs index a3067aad..a380090e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -91,13 +91,8 @@ fn choices(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::Japanese, "日本語"), (crate::i18n::Language::English, "English")], - &mut app.ui.settings.language, - ); - crate::i18n::set_language(app.ui.settings.language); + choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language); + crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); let location = match &app.session.library { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9da865fc..5300078c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -131,8 +131,6 @@ impl InfoOverlay { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct AppSettings { - /// Display language, independent of catalog data and command ids. - pub language: crate::i18n::Language, /// Library opened at launch when no `--library` is given (empty = the default location). pub library_path: String, pub startup_view: StartupView, @@ -157,7 +155,6 @@ pub struct AppSettings { impl Default for AppSettings { fn default() -> Self { AppSettings { - language: crate::i18n::default_language(), library_path: String::new(), startup_view: StartupView::Last, confirm_delete: false, @@ -203,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 = "crate::i18n::default_language")] + pub language: crate::i18n::Language, /// The Build Previews run last announced (its identity, finished?). #[serde(skip)] pub preview_build_seen: Option<(usize, bool)>, @@ -511,6 +510,7 @@ pub enum Dialog { impl Default for UiState { fn default() -> Self { UiState { + language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, luminance_map_restore: None, diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 142ec5b8..a6ce630a 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -1,8 +1,9 @@ # LightCraft 日本語版 -このローカル版は、LightCraftを基に日本語表示を追加したものです。 +開発元に追加された基本的な日本語表示(PR #110)を、編集・読み込み・書き出し画面へ広げています。 +言語設定は開発元と同じ `ui.json` の `language` を使います。以前のローカル版の `settings.language` も読み込みます。 -- 「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。 +- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。 - メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。 - 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを組み込みました。 - 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。 diff --git a/docs/parity.md b/docs/parity.md index 2572e186..c890c215 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`. diff --git a/scripts/package-japanese-macos.py b/scripts/package-japanese-macos.py index 0aead4c7..f3e50dd6 100644 --- a/scripts/package-japanese-macos.py +++ b/scripts/package-japanese-macos.py @@ -42,7 +42,7 @@ shutil.copy2(root / "target/release/lightcraft-cli", macos / "lightcraft-cli") shutil.copy2(root / "assets/app-icon/lightcraft.icns", resources / "LightCraft.icns") licenses = resources / "Licenses" licenses.mkdir() -for source in ("LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "assets/fonts/OFL-Inter.txt", "assets/fonts/OFL-BIZUDGothic.txt", "assets/app-icon/LICENSE.txt"): +for source in ("LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "assets/fonts/OFL-Inter.txt", "assets/fonts/OFL-BIZUDGothic.txt", "assets/fonts/OFL-BIZUDMincho.txt", "assets/app-icon/LICENSE.txt"): shutil.copy2(root / source, licenses / ("Icon-LICENSE.txt" if source == "assets/app-icon/LICENSE.txt" else pathlib.Path(source).name)) shutil.copy2(root / "docs/localization-ja.md", resources / "日本語版について.md") subprocess.run(["codesign", "--force", "--sign", "-", "--timestamp=none", str(macos / "lightcraft-cli")], check=True)