Merge pull request #545 from storytold/claude/integrate-batch-18
Integrate #478, #368, #460, #449, #451, #468
This commit is contained in:
commit
da6cbcf968
|
|
@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
|||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
|
|
|||
|
|
@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
|||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build
|
||||
embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
`CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build
|
||||
with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip
|
||||
without it; the FreeBSD CI job runs them with it. Rules: craftrules
|
||||
[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
|
|
|||
|
|
@ -24,13 +24,21 @@
|
|||
<script type="module">
|
||||
import init, { start, command } from "./lightcraft_web.js";
|
||||
(async () => {
|
||||
// The Chinese font is a separate file: embedding it would exceed the host's WASM size limit.
|
||||
// Font-free development builds can still run, with the same missing-glyph warning as before.
|
||||
const chineseFont = fetch("./lightcraft_zh_hans.otf")
|
||||
.then(async response => response.ok ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array())
|
||||
.catch(() => new Uint8Array());
|
||||
// Compile once; the render workers (worker.js) instantiate the same compiled module.
|
||||
const module = await WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm"));
|
||||
const [module, font] = await Promise.all([
|
||||
WebAssembly.compileStreaming(fetch("./lightcraft_web_bg.wasm")),
|
||||
chineseFont,
|
||||
]);
|
||||
globalThis.lightcraftModule = module;
|
||||
await init({ module_or_path: module });
|
||||
// automation: `await lightcraft.command("library.info", "{}")` → JSON text
|
||||
globalThis.lightcraft = { command };
|
||||
start();
|
||||
start(font);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
const el = document.getElementById("lightcraft_loading");
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@
|
|||
//!
|
||||
//! Restoring never deletes anything: the library files go to a new library folder in browser
|
||||
//! storage, originals that aren't stored yet are added (they're named by content, so existing
|
||||
//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to
|
||||
//! the restored library. The library that was there before stays in storage.
|
||||
//! ones are identical; a restored one must match its content hash, see [`verify_original`]),
|
||||
//! and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to the restored library.
|
||||
//! The library that was there before stays in storage.
|
||||
//!
|
||||
//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries.
|
||||
|
||||
|
|
@ -241,6 +242,16 @@ pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Check that an original's bytes are the photo its storage key names: originals are stored
|
||||
/// under their content hash, and a restore that kept bytes under another photo's hash would
|
||||
/// give that photo's catalog record a different image (the zip checksum can't tell).
|
||||
pub fn verify_original(entry: &ZipEntry, hash: &str, data: &[u8]) -> Result<(), String> {
|
||||
if crate::store::content_hash(data) != hash {
|
||||
return Err(format!("{}: the file doesn't match its content hash (damaged or altered backup)", entry.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A name for the restored library's folder.
|
||||
pub fn restored_dir_name(now_ms: f64) -> String {
|
||||
format!("library-restored-{}", now_ms.max(0.0) as u64)
|
||||
|
|
@ -283,6 +294,27 @@ mod tests {
|
|||
assert!(find_central(b"not a zip at all, just some text").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn originals_must_match_their_content_hash() {
|
||||
let red: &[u8] = b"red corner photo";
|
||||
let blue: &[u8] = b"blue corner photo";
|
||||
let hash = crate::store::content_hash(red);
|
||||
let name = original_entry(&hash, "IMG 1.png");
|
||||
let key = restore_key(&name, "d").unwrap();
|
||||
let key_hash = key.strip_prefix("originals/").unwrap();
|
||||
assert_eq!(key_hash, hash);
|
||||
// the entry's own bytes pass; other bytes under the same name (valid zip CRC) don't
|
||||
let zip = build(&[(name.as_str(), blue)]);
|
||||
let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..];
|
||||
let (off, size) = find_central(tail).unwrap();
|
||||
let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap();
|
||||
let entry = &entries[0];
|
||||
verify(entry, blue).unwrap();
|
||||
let err = verify_original(entry, key_hash, blue).unwrap_err();
|
||||
assert!(err.contains(&name), "{err}");
|
||||
verify_original(entry, key_hash, red).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keys_are_safe() {
|
||||
assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap"));
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use wasm_bindgen_futures::JsFuture;
|
|||
use crate::backend::Backend;
|
||||
use crate::backup::{
|
||||
ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len,
|
||||
valid_hash, verify,
|
||||
valid_hash, verify, verify_original,
|
||||
};
|
||||
use crate::files::{Files, LIBRARY_FILES};
|
||||
use crate::store::storage_key;
|
||||
|
|
@ -224,6 +224,9 @@ pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, St
|
|||
let start = data_offset(e, &local)?;
|
||||
let data = read_range(&file, start, start + e.size).await?;
|
||||
verify(e, &data)?;
|
||||
if let Some(hash) = key.strip_prefix("originals/") {
|
||||
verify_original(e, hash, &data)?;
|
||||
}
|
||||
backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?;
|
||||
written += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ struct WebApp {
|
|||
}
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self {
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
|
||||
let Boot { opts, backend, files, index, lib_dir } = boot;
|
||||
let originals = Originals::default();
|
||||
let t = perf_now();
|
||||
|
|
@ -423,7 +423,8 @@ impl WebApp {
|
|||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let frozen = Rc::new(Cell::new(false));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()));
|
||||
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
|
||||
.with_chinese_font(chinese_font);
|
||||
let ui_written = files.get("ui.json").unwrap_or_default();
|
||||
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
|
||||
app.ui = ui;
|
||||
|
|
@ -656,12 +657,15 @@ impl eframe::App for WebApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Start the app (called by `index.html` after instantiating the module).
|
||||
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
|
||||
#[wasm_bindgen]
|
||||
pub fn start() {
|
||||
pub fn start(chinese_font: Vec<u8>) {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
if chinese_font.is_empty() {
|
||||
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
|
||||
}
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// one tab per library: two would each keep their own copy and overwrite each other's saves
|
||||
|
|
@ -682,7 +686,7 @@ pub fn start() {
|
|||
};
|
||||
let boot = boot(opts).await;
|
||||
let runner = eframe::WebRunner::new();
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot))))).await;
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot, chinese_font))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
|
|
|
|||
|
|
@ -290,6 +290,39 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
|
|||
c
|
||||
}
|
||||
|
||||
/// Explorer's `/select,<path>` switch (issue #272). Explorer parses its own command line and only
|
||||
/// selects the file when the path after the comma is quoted (`/select,"C:\My Photos\a.jpg"`),
|
||||
/// spelled with backslashes and free of the verbatim prefix (`\\?\`, `\\?\UNC\`) that
|
||||
/// `canonicalize` adds; given anything else it opens Documents. Windows file names can't contain
|
||||
/// `"`; a path that does anyway (a hostile catalog) has them dropped, so the raw command line
|
||||
/// always holds exactly this one argument.
|
||||
fn explorer_select_arg(path: &str) -> String {
|
||||
let path = path.replace('/', "\\").replace('"', "");
|
||||
let path = match path.strip_prefix(r"\\?\UNC\") {
|
||||
Some(unc) => format!(r"\\{unc}"),
|
||||
None => path.strip_prefix(r"\\?\").unwrap_or(path.as_str()).to_string(),
|
||||
};
|
||||
format!("/select,\"{path}\"")
|
||||
}
|
||||
|
||||
/// `explorer /select,"<path>"`. Rust's own argument quoting wraps the whole switch in quotes
|
||||
/// (`"/select,C:\My Photos\a.jpg"`) whenever the path has a space, which Explorer doesn't
|
||||
/// recognise, so on Windows the switch goes on the command line exactly as built.
|
||||
fn explorer_select_command(path: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
let arg = explorer_select_arg(path);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.raw_arg(arg);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
c.arg(arg);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The platform services; `ctx` repaints when a file dialog closes, and `log_file` is
|
||||
/// for Help ▸ Open Log Folder.
|
||||
fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services {
|
||||
|
|
@ -377,8 +410,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
|||
let status = if cfg!(target_os = "macos") {
|
||||
std::process::Command::new("open").args(["-R", path]).status()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let win_path = path.replace('/', "\\");
|
||||
std::process::Command::new("explorer").arg(format!("/select,{win_path}")).status()
|
||||
explorer_select_command(path).status()
|
||||
} else {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into());
|
||||
std::process::Command::new("xdg-open").arg(dir).status()
|
||||
|
|
@ -928,6 +960,23 @@ mod tests {
|
|||
assert_eq!(args, ["url.dll,FileProtocolHandler", url]);
|
||||
}
|
||||
|
||||
/// Issue #272: Explorer gets the file to select quoted after `/select,`, with backslashes and
|
||||
/// without the verbatim prefix, even when the path has spaces (else it opens Documents).
|
||||
#[test]
|
||||
fn explorer_select_quotes_the_path() {
|
||||
assert_eq!(explorer_select_arg(r"C:\Users\Me\My Photos\IMG 1.jpg"), r#"/select,"C:\Users\Me\My Photos\IMG 1.jpg""#);
|
||||
assert_eq!(explorer_select_arg("D:/Example/Photos/IMG_0001.CR3"), r#"/select,"D:\Example\Photos\IMG_0001.CR3""#);
|
||||
assert_eq!(explorer_select_arg(r"\\server\share\My Photos\a.jpg"), r#"/select,"\\server\share\My Photos\a.jpg""#);
|
||||
assert_eq!(explorer_select_arg(r"\\?\D:\My Photos\a.jpg"), r#"/select,"D:\My Photos\a.jpg""#);
|
||||
assert_eq!(explorer_select_arg(r"\\?\UNC\server\share\a.jpg"), r#"/select,"\\server\share\a.jpg""#);
|
||||
// a `"` can't end the quoted path and add arguments to the raw command line
|
||||
assert_eq!(explorer_select_arg(r#"C:\a" "C:\b.jpg"#), r#"/select,"C:\a C:\b.jpg""#);
|
||||
let c = explorer_select_command(r"C:\My Photos\a.jpg");
|
||||
assert_eq!(c.get_program(), "explorer");
|
||||
let args: Vec<&std::ffi::OsStr> = c.get_args().collect();
|
||||
assert_eq!(args, [r#"/select,"C:\My Photos\a.jpg""#]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_is_not_a_number_is_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7980".into())), Some(7980));
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
|||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; embedded only in builds made with `CRAFT_FONTS_DIR` (all official releases); the web (wasm32) build embeds BIZ UDPGothic Regular only |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
|
||||
- Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho) are not in this repository. They live in
|
||||
- CJK fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC) are not in this repository. They live in
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), with their licences and attribution in its
|
||||
`ATTRIBUTION.md`, and are embedded only in builds made with the optional `CRAFT_FONTS_DIR` build input (all official
|
||||
releases). Never commit font files here; add new fonts to craft-fonts (craftrules `standards/fonts.md`).
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ impl Catalog {
|
|||
let date_of = |id: &PhotoId| -> String {
|
||||
let Some(p) = self.photo(*id) else { return String::new() };
|
||||
let d = match key {
|
||||
SortKey::CaptureDate => Some(p.date()),
|
||||
SortKey::CaptureDate => p.captured.as_deref(),
|
||||
SortKey::ImportDate => Some(p.imported.as_str()),
|
||||
SortKey::EditDate => p.edited.as_deref(),
|
||||
_ => None,
|
||||
|
|
@ -222,7 +222,7 @@ mod tests {
|
|||
}
|
||||
let shape = |r: &[DateRun]| r.iter().map(|r| (r.key.clone(), r.start, r.count)).collect::<Vec<_>>();
|
||||
let day = c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Day);
|
||||
// the undated photo falls back to its import date
|
||||
// the undated photo is grouped under Unknown Date
|
||||
assert_eq!(
|
||||
shape(&day),
|
||||
vec![
|
||||
|
|
@ -230,16 +230,20 @@ mod tests {
|
|||
("2026-09-29".into(), 2, 1),
|
||||
("2026-08-01".into(), 3, 1),
|
||||
("2025-12-31".into(), 4, 1),
|
||||
("2026-10-01".into(), 5, 1)
|
||||
(String::new(), 5, 1)
|
||||
]
|
||||
);
|
||||
assert_eq!(day[0].label, "Wednesday, 30 September 2026");
|
||||
assert_eq!(day[4].label, "Unknown Date");
|
||||
assert_eq!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Auto), day);
|
||||
assert_eq!(
|
||||
shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Month)),
|
||||
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1)]
|
||||
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Month)),
|
||||
vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1), (String::new(), 5, 1)]
|
||||
);
|
||||
assert_eq!(
|
||||
shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Year)),
|
||||
vec![("2026".into(), 0, 4), ("2025".into(), 4, 1), (String::new(), 5, 1)]
|
||||
);
|
||||
assert_eq!(shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Year)), vec![("2026".into(), 0, 4), ("2025".into(), 4, 1)]);
|
||||
assert!(c.date_runs(&ids, SortKey::FileName, GroupBy::Day).is_empty());
|
||||
assert!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::None).is_empty());
|
||||
// edit date: unedited photos have no date
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
|||
"person" | "who" => has_person(p, val),
|
||||
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
|
||||
"edited" => (val == "true" || val == "yes") == p.is_edited(),
|
||||
"date" => p.date().starts_with(val),
|
||||
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
|
||||
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
|
||||
"name" | "file" => p.file_name.to_lowercase().contains(val),
|
||||
_ => false,
|
||||
|
|
@ -284,12 +284,18 @@ impl Filter {
|
|||
return false;
|
||||
}
|
||||
if let Some(from) = &self.date_from
|
||||
&& p.date() < from.as_str()
|
||||
&& match p.captured.as_deref() {
|
||||
Some(c) => c < from.as_str(),
|
||||
None => true,
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(to) = &self.date_to
|
||||
&& p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str()
|
||||
&& match p.captured.as_deref() {
|
||||
Some(c) => c.get(..to.len()).unwrap_or(c) > to.as_str(),
|
||||
None => true,
|
||||
}
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -299,7 +305,7 @@ impl Filter {
|
|||
return false;
|
||||
}
|
||||
if let Some(d) = &self.date
|
||||
&& !p.date().starts_with(d.as_str())
|
||||
&& !p.captured.as_deref().is_some_and(|c| c.starts_with(d.as_str()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -344,7 +350,7 @@ impl Catalog {
|
|||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = match sort.key {
|
||||
SortKey::CaptureDate => a.date().cmp(b.date()),
|
||||
SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)),
|
||||
SortKey::ImportDate => a.imported.cmp(&b.imported),
|
||||
SortKey::EditDate => a.edited.cmp(&b.edited),
|
||||
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
|
||||
|
|
@ -363,7 +369,7 @@ impl Catalog {
|
|||
use std::collections::BTreeMap;
|
||||
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
|
||||
for p in self.photos().filter(|p| p.in_library()) {
|
||||
let d = p.date();
|
||||
let Some(d) = p.captured.as_deref() else { continue };
|
||||
if d.len() >= 10 {
|
||||
let (months, days) = years.entry(&d[..4]).or_default();
|
||||
*months.entry(&d[..7]).or_default() += 1;
|
||||
|
|
|
|||
|
|
@ -456,3 +456,48 @@ fn random_sort_has_no_date_headers() {
|
|||
let ids = many(&mut c, 5);
|
||||
assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undated_photos_group_under_unknown_date_and_sort_together() {
|
||||
let mut c = Catalog::new();
|
||||
let add_photo = |c: &mut Catalog, name: &str, captured: Option<&str>, imported: &str| {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 0 }, name, "JPEG", 100, 100, imported);
|
||||
p.captured = captured.map(str::to_string);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
};
|
||||
let p_dated1 = add_photo(&mut c, "dated1.jpg", Some("2026-05-10T12:00:00"), "2026-10-01T00:00:00");
|
||||
let p_dated2 = add_photo(&mut c, "dated2.jpg", Some("2026-05-20T12:00:00"), "2026-10-01T00:00:00");
|
||||
let p_undated1 = add_photo(&mut c, "undated1.jpg", None, "2026-10-05T00:00:00");
|
||||
let p_undated2 = add_photo(&mut c, "undated2.jpg", None, "2026-10-02T00:00:00");
|
||||
|
||||
// Descending sort (default, newest first): dated photos first (newest to oldest), undated photos at the end
|
||||
let desc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() });
|
||||
assert_eq!(desc, vec![p_dated2, p_dated1, p_undated1, p_undated2]);
|
||||
|
||||
// Ascending sort (oldest first): undated photos at the beginning, dated photos from oldest to newest
|
||||
let asc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() });
|
||||
assert_eq!(asc, vec![p_undated2, p_undated1, p_dated1, p_dated2]);
|
||||
|
||||
// date_runs groups undated photos under Unknown Date
|
||||
let runs = c.date_runs(&desc, SortKey::CaptureDate, GroupBy::Day);
|
||||
assert_eq!(runs.len(), 3);
|
||||
assert_eq!(runs[0].key, "2026-05-20");
|
||||
assert_eq!(runs[1].key, "2026-05-10");
|
||||
assert_eq!(runs[2].key, "");
|
||||
assert_eq!(runs[2].label, "Unknown Date");
|
||||
assert_eq!(runs[2].count, 2);
|
||||
|
||||
// date_groups sidebar tree only counts photos with capture dates
|
||||
let groups = c.date_groups();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].year, "2026");
|
||||
assert_eq!(groups[0].count, 2);
|
||||
|
||||
// Filter by capture date prefix does not match undated photos
|
||||
let filter_day = Filter { date: Some("2026-10-05".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&filter_day, &Sort::default()), vec![]);
|
||||
let filter_year = Filter { date: Some("2026".into()), ..Default::default() };
|
||||
assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,19 +13,22 @@ fn main() {
|
|||
println!("cargo::rerun-if-changed=build.rs");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
let mut embedded = 0;
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from).map(resolve) {
|
||||
match craft_fonts(&dir) {
|
||||
match craft_fonts(&dir, required) {
|
||||
Ok(entries) => {
|
||||
embedded = entries.lines().count();
|
||||
src.push_str(&entries);
|
||||
}
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
Err(e) if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
} else if required {
|
||||
println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing");
|
||||
}
|
||||
src.push_str("];\n");
|
||||
// Without them the app still runs and every language still selectable, so Chinese and Japanese
|
||||
|
|
@ -58,17 +61,21 @@ fn resolve(dir: PathBuf) -> PathBuf {
|
|||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &Path) -> Result<String, String> {
|
||||
fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
let mut has_japanese_ui = false;
|
||||
let mut has_chinese_ui = false;
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
|
||||
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
|
||||
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -82,5 +89,11 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
|
|||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
if required && !has_japanese_ui {
|
||||
return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into());
|
||||
}
|
||||
if required && !wasm && !has_chinese_ui {
|
||||
return Err("required Simplified Chinese UI face Noto Sans CJK SC Regular (Hans) is missing from fonts/manifest.txt".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -661,9 +661,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let at = before.iter().position(|id| Some(*id) == s.selection.active);
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
let vis = s.visible_cloned();
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
|
||||
s.selection.ids.retain(|id| remaining.contains(id));
|
||||
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
|
||||
if s.selection.active.is_none()
|
||||
&& let Some(at) = at
|
||||
{
|
||||
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
|
||||
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
|
||||
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
|
||||
}
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, command_specs};
|
||||
use crate::{Selection, Session, command_specs};
|
||||
|
||||
fn demo() -> Session {
|
||||
Session::with_demo()
|
||||
|
|
@ -157,6 +157,47 @@ fn filters_and_delete_restore() {
|
|||
assert_eq!(s.visible().len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_photos_selects_a_neighbour_in_the_current_view() {
|
||||
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
|
||||
for at_end in [false, true] {
|
||||
let mut s = demo();
|
||||
s.execute("library.sort", ¶ms).unwrap();
|
||||
s.execute("library.filter", &json!({"rating": 2})).unwrap();
|
||||
let visible = s.visible_cloned();
|
||||
assert!(visible.len() > 5);
|
||||
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
|
||||
let deleted = &visible[at..at + 2];
|
||||
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
|
||||
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(deleted.iter().all(|id| !s.catalog.photo(*id).unwrap().deleted));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted));
|
||||
}
|
||||
}
|
||||
let mut s = demo();
|
||||
s.execute("library.selectAll", &json!({})).unwrap();
|
||||
s.execute("photo.delete", &json!({})).unwrap();
|
||||
assert!(s.visible().is_empty());
|
||||
assert_eq!(s.selection, Selection::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_explicit_targets_keeps_the_surviving_selection() {
|
||||
let mut s = demo();
|
||||
let visible = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
|
||||
let before = s.selection.clone();
|
||||
s.execute("photo.delete", &json!({"ids": [visible[3]]})).unwrap();
|
||||
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
|
||||
s.execute("photo.delete", &json!({"ids": [visible[9]]})).unwrap();
|
||||
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_crop_and_render() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -1103,6 +1103,8 @@
|
|||
"Show destination gamut warning": "Показывать предупреждение о выходе за гамму устройства вывода",
|
||||
"Show display gamut warning": "Показывать предупреждение о выходе за гамму дисплея",
|
||||
"Show file names": "Показывать имена файлов",
|
||||
"Show in Explorer": "Показать в Проводнике",
|
||||
"Show in File Manager": "Показать в файловом менеджере",
|
||||
"Show in Finder": "Показать в Finder",
|
||||
"Show on Map": "Показать на карте",
|
||||
"Show overlay (O)": "Показать наложение (O)",
|
||||
|
|
|
|||
|
|
@ -206,6 +206,8 @@ pub struct LightcraftApp {
|
|||
/// The language the installed fonts were built for: the CJK fallback order follows the UI
|
||||
/// language's script, so switching language reinstalls them.
|
||||
font_language: i18n::Locale,
|
||||
/// Browser font loaded separately from WASM so the module stays below its host's size limit.
|
||||
chinese_font: Option<std::sync::Arc<egui::FontData>>,
|
||||
fonts_ready: bool,
|
||||
last_time: f64,
|
||||
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
|
||||
|
|
@ -291,6 +293,7 @@ impl LightcraftApp {
|
|||
synthetic_mods_release: false,
|
||||
styled: false,
|
||||
font_language: i18n::Locale::En,
|
||||
chinese_font: None,
|
||||
fonts_ready: false,
|
||||
last_time: 0.0,
|
||||
canvas_rect: None,
|
||||
|
|
@ -322,6 +325,14 @@ impl LightcraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Supply the browser's separately downloaded Simplified Chinese font before the first frame.
|
||||
pub fn with_chinese_font(mut self, bytes: Vec<u8>) -> Self {
|
||||
if !bytes.is_empty() {
|
||||
self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes)));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
// keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them
|
||||
|
|
@ -685,7 +696,7 @@ impl LightcraftApp {
|
|||
|
||||
fn logic_inner(&mut self, ctx: &egui::Context) {
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
|
||||
theme::apply(ctx);
|
||||
// File → Add from Device lists cards scanned in the background: show hot-plugs
|
||||
let repaint = ctx.clone();
|
||||
|
|
@ -699,7 +710,7 @@ impl LightcraftApp {
|
|||
} else if self.font_language != self.ui.language {
|
||||
// Shared Han characters take the active language's forms (Japanese faces for 日本語,
|
||||
// the Simplified Chinese face for 简体中文): rebuild the fallback order.
|
||||
theme::install_fonts(ctx);
|
||||
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
|
||||
self.font_language = self.ui.language;
|
||||
} else {
|
||||
self.fonts_ready = true;
|
||||
|
|
|
|||
|
|
@ -1434,6 +1434,22 @@ fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &Canvas
|
|||
}
|
||||
}
|
||||
|
||||
/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units,
|
||||
/// so undo the same scaling and rotation used to draw its outline before testing containment.
|
||||
fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool {
|
||||
let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false };
|
||||
if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let l = frame_long_norm(map);
|
||||
let dx = (at.x - center.x) / l.0;
|
||||
let dy = (at.y - center.y) / l.1;
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let x = (dx * co + dy * s) / rx;
|
||||
let y = (-dx * s + dy * co) / ry;
|
||||
x * x + y * y <= 1.0
|
||||
}
|
||||
|
||||
/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask
|
||||
/// component (click selects its mask, drag moves the component), and brush painting. The mask
|
||||
/// itself shows as a rendered overlay ([`view_overlay`]).
|
||||
|
|
@ -1608,8 +1624,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
|||
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| {
|
||||
// anywhere on the photo drags the selected linear gradient
|
||||
let m = d.masks.iter().find(|m| Some(m.id) == active)?;
|
||||
// Handles take precedence; otherwise grab the selected radial component under
|
||||
// the press, including components after the first in compound masks.
|
||||
let at = map.norm(q);
|
||||
if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) {
|
||||
return Some((m.id, ci, 0));
|
||||
}
|
||||
// anywhere on the photo drags the selected linear gradient
|
||||
matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0))
|
||||
});
|
||||
if let Some((mask, comp, handle)) = grip
|
||||
|
|
|
|||
|
|
@ -763,7 +763,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
_ => {
|
||||
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
|
||||
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label(crate::i18n::tr_format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
|
||||
ui.label(crate::i18n::tr_format!(
|
||||
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.",
|
||||
crate::theme::font_credits(app.chinese_font.is_some())
|
||||
));
|
||||
ui.add_space(10.0);
|
||||
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
|
||||
.fill(t.accent)
|
||||
|
|
|
|||
|
|
@ -1246,7 +1246,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
|
|||
ui.close();
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
&& ui.button(crate::i18n::tr("Show in Finder")).clicked()
|
||||
&& ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked()
|
||||
&& let Some(f) = app.services.reveal.as_mut()
|
||||
{
|
||||
let _ = f(path);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,78 @@ fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings {
|
|||
(*h.app.session.develop_of(id).unwrap_or_default()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_body_drag_moves_rotated_components_and_undoes_once() {
|
||||
let mut h = detail("panel.masking");
|
||||
exec(&mut h, "mask.add", json!({"kind": "luminanceRange"}));
|
||||
exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "add", "center": [0.45, 0.5], "rx": 0.22, "ry": 0.1, "angle": 40}));
|
||||
let before = develop(&h);
|
||||
// Well away from the pin and edge/rotation grips, inside a rotated ellipse.
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": 0.51, "y": 0.56}, {"kind": "drag", "x": 0.54, "y": 0.58},
|
||||
{"kind": "drag", "x": 0.61, "y": 0.66}, {"kind": "up", "x": 0.61, "y": 0.66}]),
|
||||
);
|
||||
let after = develop(&h);
|
||||
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &after.masks[0].components[1].shape else { panic!("radial") };
|
||||
assert!((center.x - 0.55).abs() < 0.005 && (center.y - 0.6).abs() < 0.005, "{center:?}");
|
||||
let MaskShape::Radial { rx: old_rx, ry: old_ry, angle: old_angle, feather: old_feather, invert: old_invert, .. } =
|
||||
&before.masks[0].components[1].shape
|
||||
else {
|
||||
panic!("radial")
|
||||
};
|
||||
assert_eq!((rx, ry, angle, feather, invert), (old_rx, old_ry, old_angle, old_feather, old_invert));
|
||||
assert_eq!(after.masks[0].components[0], before.masks[0].components[0], "only the hit component moves");
|
||||
exec(&mut h, "edit.undo", json!({}));
|
||||
assert_eq!(develop(&h), before, "one undo reverses the entire drag");
|
||||
exec(&mut h, "edit.redo", json!({}));
|
||||
assert_eq!(develop(&h), after);
|
||||
// The bounding-box corner is outside the ellipse, even for an inverted radial mask.
|
||||
exec(&mut h, "mask.component", json!({"component": 1, "action": "invert"}));
|
||||
let outside_before = develop(&h);
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": 0.35, "y": 0.75}, {"kind": "drag", "x": 0.4, "y": 0.8},
|
||||
{"kind": "up", "x": 0.4, "y": 0.8}]),
|
||||
);
|
||||
assert_eq!(develop(&h), outside_before, "outside the ellipse must not move it");
|
||||
// Hiding pins does not disable dragging the selected ellipse body.
|
||||
exec(&mut h, "view.maskPins", json!({"show": false}));
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": 0.61, "y": 0.66}, {"kind": "drag", "x": 0.66, "y": 0.71},
|
||||
{"kind": "up", "x": 0.66, "y": 0.71}]),
|
||||
);
|
||||
let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[1].shape.clone() else { panic!("radial") };
|
||||
assert!((center.x - 0.6).abs() < 0.005 && (center.y - 0.65).abs() < 0.005, "{center:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_body_drag_keeps_resize_and_rotation_handles() {
|
||||
let mut h = detail("panel.masking");
|
||||
exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.18, "ry": 0.1}));
|
||||
for handle in [1, 5] {
|
||||
let widget = format!("maskHandle:1:0:{handle}");
|
||||
let rect = h.app.widgets.iter().find(|(id, _)| *id == widget).unwrap().1;
|
||||
let image = h.app.image_rect.unwrap();
|
||||
let q = rect.center();
|
||||
let x = (q.x - image.left()) / image.width();
|
||||
let y = (q.y - image.top()) / image.height();
|
||||
pointer(
|
||||
&mut h,
|
||||
json!([{"kind": "down", "x": x, "y": y}, {"kind": "drag", "x": x + 0.03, "y": y + 0.03},
|
||||
{"kind": "up", "x": x + 0.03, "y": y + 0.03}]),
|
||||
);
|
||||
let MaskShape::Radial { center, rx, angle, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") };
|
||||
assert!((center.x - 0.5).abs() < 0.005 && (center.y - 0.5).abs() < 0.005, "a handle must not translate the mask");
|
||||
if handle == 1 {
|
||||
assert!(rx > 0.19, "resize grip changes radius");
|
||||
} else {
|
||||
assert!(angle.abs() > 1.0, "rotation grip changes angle");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_overlay_keys_and_pins() {
|
||||
use lightcraft_pipeline::{MaskView, Overlay};
|
||||
|
|
|
|||
|
|
@ -84,6 +84,33 @@ fn grid_keeps_the_users_scroll_position() {
|
|||
assert!(grid_y(&h) < y2, "scrolled up to the second photo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_photos_does_not_jump_the_grid_to_the_top() {
|
||||
for view in ["photoGrid", "squareGrid"] {
|
||||
for count in [1, 3] {
|
||||
let mut h = demo(view);
|
||||
let ids = h.app.session.visible_cloned();
|
||||
let at = ids.len() / 2;
|
||||
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": &ids[at..at + count]}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
idle(&mut h, 30);
|
||||
let y = grid_y(&h);
|
||||
assert!(y > 600.0, "selection is well below the top: {view}, {y}");
|
||||
let r = h.request("engine.execute", json!({"command": "photo.delete"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
idle(&mut h, 60);
|
||||
let next = ids[at + count];
|
||||
assert!(grid_y(&h) > 600.0, "deleting scrolled to the top: {view}, {} → {}", y, grid_y(&h));
|
||||
assert_eq!(h.app.session.active(), Some(next), "{view}, count {count}");
|
||||
let canvas = h.app.canvas_rect.unwrap();
|
||||
assert!(widget(&h, &format!("thumb:{}", next.0)).intersects(canvas), "the next survivor is in view");
|
||||
let after = grid_y(&h);
|
||||
idle(&mut h, 60);
|
||||
assert_eq!(grid_y(&h), after, "idle frames preserve the new position");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filmstrip_wheel_scrolls_and_keeps_its_position() {
|
||||
let mut h = demo("detail");
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::sync::Arc;
|
|||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
||||
|
||||
pub const FONT_SEMIBOLD: &str = "semibold";
|
||||
const WEB_CHINESE_FONT: &str = "web Noto Sans CJK SC";
|
||||
|
||||
/// Shared colour-label palette for badges, thumbnail surrounds and feedback.
|
||||
pub fn label_color(label: lightcraft_catalog::ColorLabel) -> Color32 {
|
||||
|
|
@ -129,14 +130,24 @@ impl Tokens {
|
|||
}
|
||||
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS));
|
||||
install_fonts_with_chinese(ctx, None);
|
||||
}
|
||||
|
||||
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last
|
||||
/// fallback of every family — the active language's own script first, so shared Han characters keep
|
||||
/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as
|
||||
/// boxes.
|
||||
/// Install the browser's separately downloaded Chinese face alongside the embedded faces.
|
||||
pub fn install_fonts_with_chinese(ctx: &egui::Context, chinese: Option<&Arc<FontData>>) {
|
||||
ctx.set_fonts(font_definitions_with_chinese(lightcraft_engine::CRAFT_FONTS, chinese));
|
||||
}
|
||||
|
||||
/// Inter remains the default UI face. Egui's default faces and craft-fonts cover other scripts;
|
||||
/// the browser's separate Chinese face precedes Japanese faces within the CJK fallback list.
|
||||
/// Without craft-fonts or that separate face, CJK text shows boxes.
|
||||
pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions {
|
||||
font_definitions_with_chinese(craft, None)
|
||||
}
|
||||
|
||||
/// The web build keeps the large Simplified Chinese face outside the size-limited WASM module.
|
||||
/// Prefer it for Chinese UI text, including Traditional Chinese until it has its own face.
|
||||
pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFont], chinese: Option<&Arc<FontData>>) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
|
||||
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
|
||||
|
|
@ -147,12 +158,24 @@ pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontD
|
|||
.map(craft_font_name)
|
||||
.collect::<Vec<String>>()
|
||||
};
|
||||
let (regular, bold) = (fallback("Regular"), fallback("Bold"));
|
||||
let (mut regular, mut bold) = (fallback("Regular"), fallback("Bold"));
|
||||
for name in regular.iter().chain(bold.iter()) {
|
||||
if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) {
|
||||
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes)));
|
||||
}
|
||||
}
|
||||
let script = crate::i18n::language().script();
|
||||
let prefer_chinese = chinese.is_some() && matches!(script, "Hans" | "Hant");
|
||||
if let Some(font) = chinese {
|
||||
fonts.font_data.insert(WEB_CHINESE_FONT.into(), font.clone());
|
||||
if prefer_chinese {
|
||||
regular.insert(0, WEB_CHINESE_FONT.into());
|
||||
bold.insert(0, WEB_CHINESE_FONT.into());
|
||||
} else {
|
||||
regular.push(WEB_CHINESE_FONT.into());
|
||||
bold.push(WEB_CHINESE_FONT.into());
|
||||
}
|
||||
}
|
||||
let defaults: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let mut prop = vec!["Inter".to_string()];
|
||||
prop.extend(defaults.iter().cloned());
|
||||
|
|
@ -162,19 +185,29 @@ pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontD
|
|||
semi.extend(defaults);
|
||||
semi.extend(bold);
|
||||
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular"));
|
||||
let mono = fonts.families.entry(FontFamily::Monospace).or_default();
|
||||
let mut mono_fallback = fallback("Regular");
|
||||
if prefer_chinese {
|
||||
mono_fallback.insert(0, WEB_CHINESE_FONT.into());
|
||||
}
|
||||
if chinese.is_some() && !prefer_chinese {
|
||||
mono_fallback.push(WEB_CHINESE_FONT.into());
|
||||
}
|
||||
mono.extend(mono_fallback);
|
||||
fonts
|
||||
}
|
||||
|
||||
/// The embedded font families for the About box: Inter, plus the craft-fonts families when built
|
||||
/// with them.
|
||||
pub fn font_credits() -> String {
|
||||
/// The font families used by this app instance, including the browser's separately loaded face.
|
||||
pub fn font_credits(chinese_loaded: bool) -> String {
|
||||
let mut families = vec!["Inter"];
|
||||
for f in lightcraft_engine::CRAFT_FONTS {
|
||||
if !families.contains(&f.family) {
|
||||
families.push(f.family);
|
||||
}
|
||||
}
|
||||
if chinese_loaded && !families.contains(&"Noto Sans CJK SC") {
|
||||
families.push("Noto Sans CJK SC");
|
||||
}
|
||||
families.join(" / ")
|
||||
}
|
||||
|
||||
|
|
@ -225,3 +258,88 @@ pub fn apply(ctx: &egui::Context) {
|
|||
s.animation_time = 0.08;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::i18n::{Locale, set_language};
|
||||
|
||||
#[test]
|
||||
fn native_chinese_face_falls_back_after_default_faces() {
|
||||
static FACES: &[lightcraft_engine::CraftFont] = &[
|
||||
lightcraft_engine::CraftFont {
|
||||
family: "BIZ UDPGothic",
|
||||
style: "Regular",
|
||||
scripts: &["Jpan", "Latn"],
|
||||
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
|
||||
},
|
||||
lightcraft_engine::CraftFont {
|
||||
family: "Noto Sans CJK SC",
|
||||
style: "Regular",
|
||||
scripts: &["Hans", "Latn"],
|
||||
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
|
||||
},
|
||||
];
|
||||
set_language(Locale::ZhHans);
|
||||
let definitions = font_definitions(FACES);
|
||||
let defaults = FontDefinitions::default();
|
||||
let default_prop = defaults.families.get(&FontFamily::Proportional).expect("default proportional fonts");
|
||||
let chinese = "craft-fonts Noto Sans CJK SC Regular";
|
||||
for (family, primary) in [(FontFamily::Proportional, "Inter"), (FontFamily::Name(FONT_SEMIBOLD.into()), "Inter-SemiBold")] {
|
||||
let names = definitions.families.get(&family).expect("UI font family");
|
||||
assert_eq!(names.first().map(String::as_str), Some(primary));
|
||||
assert_eq!(names.get(1), default_prop.first());
|
||||
assert!(names.iter().position(|name| name == chinese).is_some_and(|position| position > default_prop.len()));
|
||||
}
|
||||
let mono = definitions.families.get(&FontFamily::Monospace).expect("monospace font family");
|
||||
let default_mono = defaults.families.get(&FontFamily::Monospace).expect("default monospace fonts");
|
||||
assert_eq!(mono.first(), default_mono.first());
|
||||
assert!(mono.iter().position(|name| name == chinese).is_some_and(|position| position >= default_mono.len()));
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_web_chinese_face_is_available_in_every_ui_family() {
|
||||
// Simulate the WASM build, which embeds only the Japanese face. The Chinese face arrives
|
||||
// as a separate file and must precede that face when the UI uses Chinese.
|
||||
static JAPANESE: &[lightcraft_engine::CraftFont] = &[lightcraft_engine::CraftFont {
|
||||
family: "BIZ UDPGothic",
|
||||
style: "Regular",
|
||||
scripts: &["Jpan", "Latn"],
|
||||
bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
|
||||
}];
|
||||
let embedded = lightcraft_engine::CRAFT_FONTS.iter().find(|font| font.family == "Noto Sans CJK SC" && font.style == "Regular");
|
||||
let fallback: &'static [u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
let source = embedded.map_or(fallback, |font| font.bytes);
|
||||
let chinese = Arc::new(FontData::from_static(source));
|
||||
set_language(Locale::ZhHans);
|
||||
let definitions = font_definitions_with_chinese(JAPANESE, Some(&chinese));
|
||||
let default_count = FontDefinitions::default().families.get(&FontFamily::Proportional).map_or(0, Vec::len);
|
||||
for (family, primary) in [(FontFamily::Proportional, "Inter"), (FontFamily::Name(FONT_SEMIBOLD.into()), "Inter-SemiBold")] {
|
||||
let names = definitions.families.get(&family).expect("UI font family");
|
||||
assert_eq!(names.first().map(String::as_str), Some(primary));
|
||||
assert_eq!(names.get(1 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT));
|
||||
}
|
||||
assert!(definitions.families.get(&FontFamily::Monospace).is_some_and(|names| names.contains(&WEB_CHINESE_FONT.to_string())));
|
||||
|
||||
if embedded.is_some() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(definitions);
|
||||
let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
frame.textures_delta.clear();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] {
|
||||
let id = FontId::new(13.0, family);
|
||||
assert!("简体中文字".chars().all(|ch| fonts.has_glyph(&id, ch)), "{id:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set_language(Locale::Ja);
|
||||
let japanese = font_definitions_with_chinese(JAPANESE, Some(&chinese));
|
||||
let names = japanese.families.get(&FontFamily::Proportional).expect("proportional fonts");
|
||||
assert_eq!(names.get(1 + default_count).map(String::as_str), Some("craft-fonts BIZ UDPGothic Regular"));
|
||||
assert_eq!(names.get(2 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT));
|
||||
set_language(Locale::En);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
|
|||
`cargo::warning` 提示这一点。
|
||||
- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。
|
||||
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
|
||||
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
|
||||
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
|
||||
原生桌面版不受影响。
|
||||
- 网页版将 Noto Sans CJK SC 作为独立的 `lightcraft_zh_hans.otf` 文件随站点分发,页面启动时加载;
|
||||
这样可显示简体中文,也避免把约 16 MB 字体放进受单文件大小限制的 WASM。部署时需上传整个
|
||||
`target/web/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。
|
||||
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
|
||||
|
||||
## 翻译的维护
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
|
|||
|
||||
craft-fonts 目前沒有繁體中文專用字型:桌面版先以 Noto Sans CJK SC 顯示繁中文字(字形為大陸規範寫法),其次才是日文字型;不在此儲存庫新增字型檔。
|
||||
未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。
|
||||
Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
|
||||
Web 建置在 WASM 中嵌入 BIZ UDPGothic Regular,並另外載入 Noto Sans CJK SC;繁中文字形仍採用簡中規範寫法。
|
||||
|
||||
## 自動化與維護
|
||||
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back |
|
||||
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
|
||||
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
|
||||
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) |
|
||||
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Deletion retains surviving selected photos; if none remain, selects the next visible survivor (previous at the end) in the current source/filter/sort instead of jumping to the first photo; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) |
|
||||
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
|
||||
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
|
||||
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow |
|
||||
|
|
@ -354,7 +354,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
|
||||
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
|
||||
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | |
|
||||
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
|
||||
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider |
|
||||
| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) |
|
||||
| LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data |
|
||||
|
|
@ -527,7 +527,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
|
||||
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
|
||||
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
|
||||
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ workflow, and bump it (in `release.yml`, five jobs, and `freebsd.yml`) whenever
|
|||
shipped language needs: a stale pin still builds, it just ships tofu (issue #319: v0.4.0 pinned a revision
|
||||
from before Noto Sans CJK SC, so Simplified Chinese had no glyphs). Before a release, check the pin against
|
||||
craft-fonts' `fonts/manifest.txt` and run `CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n`,
|
||||
whose glyph-coverage test fails when a language's characters have no face.
|
||||
whose glyph-coverage test checks the characters in available faces. `CRAFT_FONTS_REQUIRED=1` now also makes
|
||||
the native build fail if the pinned manifest lacks Noto Sans CJK SC Regular (`Hans`) or BIZ UDPGothic Regular;
|
||||
the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a separate asset.
|
||||
|
||||
## Signing credentials
|
||||
|
||||
|
|
|
|||
18
docs/web.md
18
docs/web.md
|
|
@ -52,13 +52,14 @@ codecs) at opt-level 3 and the rest (egui, eframe, serde, glue) at "s". What goe
|
|||
is the brotli column: 3.1 MB, 11 % less than the old build's brotli size and 41 % less than its
|
||||
gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further.
|
||||
|
||||
**Fonts.** The browser has no system fonts to fall back on, so Japanese text comes from
|
||||
**Fonts.** The browser has no system fonts to fall back on, so CJK text comes from
|
||||
[craft-fonts](https://github.com/storytold/craft-fonts), the optional `CRAFT_FONTS_DIR` build
|
||||
input (`CRAFT_FONTS_DIR=../craft-fonts cargo xtask web`; release builds always set it). On wasm32
|
||||
`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI, and the watermark fallback), so
|
||||
the module stays well under Cloudflare's 25 MiB per-file limit: measured 2026-10-06, 17.1 MB
|
||||
without craft-fonts and 21.8 MB with it (brotli 3.8 MB / 6.3 MB). Without it the web build
|
||||
works, but Japanese text has no glyphs.
|
||||
`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI and watermark fallback). The web
|
||||
bundle also ships Noto Sans CJK SC Regular as `lightcraft_zh_hans.otf` and loads it before the UI
|
||||
starts. Keeping the 16 MB Chinese face separate leaves the module under Cloudflare's 25 MiB
|
||||
per-file limit (the older Japanese-only module measured 21.8 MB). Without `CRAFT_FONTS_DIR`, the
|
||||
web build still works, but Chinese and Japanese text lack glyphs.
|
||||
|
||||
## Deploying: headers
|
||||
|
||||
|
|
@ -111,9 +112,10 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not
|
|||
(`originals/<hash>/<file name>`, so an unzipped backup is browsable; entries are stored, not
|
||||
compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a
|
||||
new library folder (`library-restored-<time>/`; originals already stored are skipped, every
|
||||
entry's checksum is verified) and only then switches to it (`active-library` names the
|
||||
folder in use) and reloads: the previous library stays in storage. Both are web-only
|
||||
(`file.backupLibrary`, `file.restoreLibrary`; the desktop library is a folder).
|
||||
entry's checksum is verified, and each restored original must match its content hash) and
|
||||
only then switches to it (`active-library` names the folder in use) and reloads: the previous
|
||||
library stays in storage. Both are web-only (`file.backupLibrary`, `file.restoreLibrary`; the
|
||||
desktop library is a folder).
|
||||
- A failed save (quota exceeded, storage cleared) is not silent: the catalog then refuses new
|
||||
writes, so commands report `saved in memory but not written`, the top bar shows the unsaved
|
||||
warning (as on the desktop, see `docs/control-protocol.md`), and saving is retried every
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## October 2026
|
||||
|
||||
### Masking
|
||||
- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated
|
||||
ellipses and radial components in compound masks. Edge and rotation handles keep their existing
|
||||
functions, and one Undo restores the entire drag.
|
||||
|
||||
### Albums tree
|
||||
- Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords.
|
||||
- Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ static site in `lightcraft-web-<version>/`:
|
|||
| `lightcraft_web.js` | wasm-bindgen glue (generated, ES module) |
|
||||
| `lightcraft_web_bg.wasm` | The app, about 13 MB (about 3 MB with brotli) |
|
||||
| `worker.js` | Starts the render workers (each runs the same module) |
|
||||
| `lightcraft_zh_hans.otf` | Simplified Chinese UI font, included when built with `CRAFT_FONTS_DIR` |
|
||||
| `OFL-noto-sans-cjk-sc.txt` | The bundled Chinese font's SIL Open Font License |
|
||||
| `*.gz`, `*.br` | Precompressed copies of the files above (optional to serve) |
|
||||
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ use std::process::Command;
|
|||
use crate::{cargo, metadata, root, run as step};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const CHINESE_FONT_FILE: &str = "lightcraft_zh_hans.otf";
|
||||
const CHINESE_FONT_LICENSE_FILE: &str = "OFL-noto-sans-cjk-sc.txt";
|
||||
|
||||
/// Files copied from `apps/lightcraft-web/` into the bundle as they are.
|
||||
const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"];
|
||||
|
|
@ -20,6 +22,51 @@ const STATIC_FILES: [&str; 2] = ["index.html", "worker.js"];
|
|||
/// Bundle files that get precompressed copies.
|
||||
const COMPRESSED: [&str; 4] = ["index.html", "worker.js", "lightcraft_web.js", "lightcraft_web_bg.wasm"];
|
||||
|
||||
/// The Noto face is shipped beside the WASM rather than embedded in it: hosts such as Cloudflare
|
||||
/// Pages cap individual files at 25 MiB, while the complete site can contain larger assets.
|
||||
fn chinese_font_in_manifest(manifest: &str) -> Option<(&str, &str)> {
|
||||
manifest.lines().find_map(|line| {
|
||||
let fields: Vec<_> = line.split('|').map(str::trim).collect();
|
||||
if fields.len() >= 6 && fields[0] == "Noto Sans CJK SC" && fields[1] == "Regular" && !fields[2].is_empty() && !fields[5].is_empty() {
|
||||
Some((fields[2], fields[5]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_chinese_font(out: &Path) -> Result<bool, String> {
|
||||
// A previous font-enabled build must not leave an unlicensed/stale asset in a font-free one.
|
||||
for name in [CHINESE_FONT_FILE, CHINESE_FONT_LICENSE_FILE, "lightcraft_zh_hans.otf.gz", "lightcraft_zh_hans.otf.br"] {
|
||||
let path = out.join(name);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => (),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(e) => return Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR") else {
|
||||
if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() {
|
||||
return Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into());
|
||||
}
|
||||
return Ok(false);
|
||||
};
|
||||
let dir = PathBuf::from(dir);
|
||||
let dir = if dir.is_absolute() { dir } else { root().join(dir) };
|
||||
let manifest_path = dir.join("fonts/manifest.txt");
|
||||
let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
|
||||
let (font, licence) =
|
||||
chinese_font_in_manifest(&manifest).ok_or_else(|| format!("{}: Noto Sans CJK SC Regular is missing", manifest_path.display()))?;
|
||||
let licence = dir.join(licence);
|
||||
let licence_target = out.join(CHINESE_FONT_LICENSE_FILE);
|
||||
std::fs::copy(&licence, &licence_target).map_err(|e| format!("{} → {}: {e}", licence.display(), licence_target.display()))?;
|
||||
let source = dir.join(font);
|
||||
let target = out.join(CHINESE_FONT_FILE);
|
||||
std::fs::copy(&source, &target).map_err(|e| format!("{} → {}: {e}", source.display(), target.display()))?;
|
||||
println!("web Chinese font: {} → {}", source.display(), target.display());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The `wasm-bindgen` version pinned in Cargo.lock (the CLI must match it exactly).
|
||||
fn locked_bindgen_version() -> Result<String, String> {
|
||||
let lock = std::fs::read_to_string(root().join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?;
|
||||
|
|
@ -84,10 +131,11 @@ pub fn run(args: &[&str]) -> Result<(), String> {
|
|||
for f in STATIC_FILES {
|
||||
std::fs::copy(root().join("apps/lightcraft-web").join(f), out.join(f)).map_err(|e| format!("copy {f}: {e}"))?;
|
||||
}
|
||||
let chinese_font = copy_chinese_font(&out)?;
|
||||
|
||||
// precompressed copies (skipped for --dev: they'd only slow the edit loop down)
|
||||
println!("\n{:<26} {:>12} {:>12} {:>12}", "file", "bytes", "gzip -9", "brotli -11");
|
||||
for f in COMPRESSED {
|
||||
for f in COMPRESSED.into_iter().chain(chinese_font.then_some(CHINESE_FONT_FILE)) {
|
||||
let p = out.join(f);
|
||||
let raw = std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?;
|
||||
let (gz, br) = if dev {
|
||||
|
|
@ -138,6 +186,8 @@ fn mime(path: &Path) -> &'static str {
|
|||
"json" => "application/json",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"otf" => "font/otf",
|
||||
"txt" => "text/plain; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
|
|
@ -242,6 +292,16 @@ mod tests {
|
|||
assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm");
|
||||
assert_eq!(mime(Path::new("index.html")), "text/html; charset=utf-8");
|
||||
assert_eq!(mime(Path::new("worker.js")), "text/javascript; charset=utf-8");
|
||||
assert_eq!(mime(Path::new(CHINESE_FONT_FILE)), "font/otf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_bundle_selects_the_chinese_face_from_the_font_manifest() {
|
||||
let manifest = "# family | style | path | scripts | licence | licence file\n\
|
||||
BIZ UDPGothic | Regular | fonts/biz.ttf | Jpan,Latn | OFL-1.1 | fonts/biz/OFL.txt\n\
|
||||
Noto Sans CJK SC | Regular | fonts/noto/NotoSansCJKsc-Regular.otf | Hans,Latn | OFL-1.1 | fonts/noto/OFL.txt\n";
|
||||
assert_eq!(chinese_font_in_manifest(manifest), Some(("fonts/noto/NotoSansCJKsc-Regular.otf", "fonts/noto/OFL.txt")));
|
||||
assert_eq!(chinese_font_in_manifest("BIZ UDPGothic | Regular | fonts/biz.ttf | Jpan,Latn | OFL-1.1 | fonts/biz/OFL.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in New Issue