M16: integrate expanded Japanese UI with upstream language settings
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
commit
509d5843dd
4
NOTICE
4
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.
|
||||
|
|
|
|||
10
ROADMAP.md
10
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.
|
||||
|
|
|
|||
|
|
@ -85,7 +85,18 @@ fn load_prefs() -> Option<UiState> {
|
|||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
|
||||
serde_json::from_slice::<UiState>(&bytes).ok().map(UiState::sanitized)
|
||||
decode_saved_ui(&bytes)
|
||||
}
|
||||
|
||||
fn decode_saved_ui(bytes: &[u8]) -> Option<UiState> {
|
||||
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::<UiState>(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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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.
|
||||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
|||
"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}"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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": "縦書き"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 }
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static LANGUAGE: Cell<Language> = const { Cell::new(Language::English) };
|
||||
static LANGUAGE: Cell<Language> = 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<String, String> {
|
||||
|
|
@ -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::<Vec<_>>();
|
||||
let japanese_ids = ids(&app);
|
||||
set_language(Language::English);
|
||||
set_language(Language::En);
|
||||
assert_eq!(japanese_ids, ids(&app));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -259,6 +259,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),
|
||||
"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::<f32>()
|
||||
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::<f32>()
|
||||
}
|
||||
|
||||
/// 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<f32> =
|
||||
bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + TITLE_GAP).collect();
|
||||
let widths: Vec<f32> = 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();
|
||||
|
|
|
|||
|
|
@ -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<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 };
|
||||
return Some(Ok(json!(app.ui.language)));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let r: Result<Value, String> = match id {
|
||||
"view.photoGrid" => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -91,13 +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::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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# LightCraft 日本語版
|
||||
|
||||
このローカル版は、LightCraftを基に日本語表示を追加したものです。
|
||||
開発元に追加された基本的な日本語表示(PR #110)を、編集・読み込み・書き出し画面へ広げています。
|
||||
言語設定は開発元と同じ `ui.json` の `language` を使います。以前のローカル版の `settings.language` も読み込みます。
|
||||
|
||||
- 「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。
|
||||
- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。
|
||||
- メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。
|
||||
- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを組み込みました。
|
||||
- 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue