Merge pull request #545 from storytold/claude/integrate-batch-18

Integrate #478, #368, #460, #449, #451, #468
This commit is contained in:
Brandon Thomas 2026-10-09 11:56:38 -04:00 committed by GitHub
commit da6cbcf968
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 613 additions and 70 deletions

View File

@ -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).

View File

@ -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).

View File

@ -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");

View File

@ -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"));

View File

@ -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;
}

View File

@ -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")) {

View File

@ -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));

View File

@ -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`).

View File

@ -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

View File

@ -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;

View File

@ -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]);
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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", &params).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();

View File

@ -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)",

View File

@ -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;

View File

@ -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

View File

@ -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)

View File

@ -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);

View File

@ -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};

View File

@ -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");

View File

@ -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);
}
}

View File

@ -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日 星期日`)。
## 翻译的维护

View File

@ -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;繁中文字形仍採用簡中規範寫法。
## 自動化與維護

View File

@ -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 | ⬜ | | |

View File

@ -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

View File

@ -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

View File

@ -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…

View File

@ -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 |

View File

@ -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]