mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
* build(i18n): embed the craft-i18n catalogs from a pinned checkout
- `craft-i18n.rev` pins storytold/craft-i18n at e45ddf7 (the PdfCraft cutover sync of
pdfcraft@ecdd1e6); `.gitattributes` keeps it LF.
- The `craft-i18n` crate (craft-libs 787a7a0, PdfCraft's existing pin) is a direct dependency
of the UI crate; it was already in the tree through craft-ui.
- `crates/ui-egui/build.rs` writes `CRAFT_I18N` from `CRAFT_I18N_DIR`: one (locale, common.tsv,
pdfcraft.tsv) entry per locale with a `pdfcraft.tsv`, paths written with `{:?}` and never
canonicalized. Unset it is empty (English only); `CRAFT_I18N_REQUIRED=1` makes a missing or
invalid checkout an error.
- Both flatpak manifests name the craft-i18n commit and `CRAFT_I18N_DIR`; a test keeps both
commits equal to the trimmed pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(i18n): look strings up through craft-i18n behind a thin Lang wrapper
`i18n.rs` keeps PdfCraft's API (tr, tr_ctx, tr_id, trn, key_name, fmt,
Lang, LANGUAGES, detection) and hands lookups to the craft-i18n crate:
`get` for key names, `trn_plain` for plurals (no isolates, no digit
grouping), PdfCraft's own `fmt` (no FSI isolates). `@id` rows and the
"Undo {action}" template are read only where PdfCraft had them, so menus
and history labels read exactly as before.
Detection is unchanged (LC_ALL/LC_MESSAGES/LANG, macOS defaults,
sys_locale); the crate's match_tag would drop nb for no/nn, so PdfCraft's
own tag mapping stays, with one parity test per path.
Completeness tests become reports; tests that need catalogs skip with a
note when the build has none. Font coverage tests read the embedded
catalogs (shared and PdfCraft rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(i18n): remove the in-repo catalogs; craft-i18n holds them
The 34 `crates/ui-egui/src/i18n/*.tsv` catalogs were migrated into
craft-i18n at pdfcraft@ecdd1e6 and are now embedded from the pinned
checkout. ATTRIBUTION.toml drops their 34 entries for one craft-i18n
`[[build_input]]` (craft-i18n's ATTRIBUTION.md credits the translators);
AGENTS.md and docs/localization.md describe the new system and how to
add a language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(i18n): skip catalog tests without craft-i18n; drop the catalog hash exemption
Tests that switch to a non-English language skip with a note when the
build embeds no catalogs (CRAFT_I18N_DIR unset), so the English-only
build tests green. `skip_without_catalogs` moves to the integration
test support module.
With no catalogs left in the repository, `cargo xtask assets` no longer
exempts `crates/ui-egui/src/i18n/*.tsv` from hashing: every `[[asset]]`
carries a sha256 again, and a `.tsv` added back would need one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): watch the locales directory before reading it
A CRAFT_I18N_DIR that doesn't exist yet now reruns the build script once it appears (review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): pin craft-i18n dfbe955, the merge of storytold/craft-i18n#129
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): skip an @id row that changes the label's placeholders or ellipsis
craft-i18n checks placeholders and the trailing ellipsis on every row whose
source is English text, but not on @id rows, whose source is the command id.
The in-repo catalog test used to fail on such a row; since the cutover it only
prints it, while tr_id and menu_label still showed it. Both now use PdfCraft's
own @id row only when it keeps the label's shape (same_shape, the rule
craft-i18n applies elsewhere), and otherwise show the label's translation, as
bundled_catalogs_are_consistent says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with an open brace; list unused @id rows in the gaps report
same_shape skipped a `{` with no `}`, so an @id row such as "Öffnen {name…"
for "Open…" was still shown. It now counts as a changed shape.
bundled_catalogs_are_consistent printed the @id rows PdfCraft doesn't use, but
CI captures test output, so nobody saw them. translation_gaps_report, which
`cargo xtask i18n-report` runs with --nocapture, now lists them
(unused_id_rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with a `}` that closes no placeholder
same_shape counted only the `}` that close a `{`, so "Öffnen name}…" for
"Open…" or "{name}} öffnen" for "Open {name}" still passed. A row whose `}`
count differs from its placeholders now counts as a changed shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): no braces in the assert messages of the brace test
assert! reads its message as a format string, so "a `}` without its `{`"
did not compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
188 lines
8.3 KiB
Rust
188 lines
8.3 KiB
Rust
//! Bake the contributor credits into the binary as static Rust tables, and embed the craft-i18n
|
|
//! interface catalogs. Nothing is read at run time.
|
|
//!
|
|
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
|
|
//! entry is verified.
|
|
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
|
//! JSON says the entry was verified (`people_entry`).
|
|
//!
|
|
//! A missing or malformed file gives empty tables (or no names) and a warning.
|
|
//!
|
|
//! - `CRAFT_I18N_DIR`, a craft-i18n checkout (storytold/craft-i18n, at the SHA in
|
|
//! `craft-i18n.rev`): `craft_i18n.rs` holds `CRAFT_I18N`, one (locale, shared catalog, PdfCraft
|
|
//! catalog) entry per locale that has a `pdfcraft.tsv`. Unset, or not a checkout (a warning),
|
|
//! it is empty and the interface is English only; with `CRAFT_I18N_REQUIRED=1` (CI and releases)
|
|
//! that is an error instead. Recipe: craft-i18n's `docs/integration.md`.
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
use serde_json::Value;
|
|
|
|
fn main() {
|
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
|
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
|
|
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
|
|
})
|
|
.unwrap_or(Value::Null);
|
|
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
|
.unwrap_or_default();
|
|
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
|
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
|
println!("cargo::warning=writing {}: {e}", out.display());
|
|
}
|
|
let out = out.with_file_name("craft_i18n.rs");
|
|
if let Err(e) = std::fs::write(&out, craft_i18n()) {
|
|
println!("cargo::error=writing {}: {e}", out.display());
|
|
}
|
|
}
|
|
|
|
/// The catalog this app reads in each locale: `locales/<locale>/pdfcraft.tsv`.
|
|
const APP_CATALOG: &str = "pdfcraft.tsv";
|
|
|
|
/// `CRAFT_I18N` from `CRAFT_I18N_DIR`: the `Embedded` list the craft-i18n crate reads.
|
|
fn craft_i18n() -> String {
|
|
println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR");
|
|
println!("cargo::rerun-if-env-changed=CRAFT_I18N_REQUIRED");
|
|
let required = std::env::var("CRAFT_I18N_REQUIRED").is_ok_and(|v| v.trim() == "1");
|
|
let entries = match std::env::var_os("CRAFT_I18N_DIR").filter(|d| !d.is_empty()) {
|
|
// Not canonicalized: on Windows that gives `\\?\C:\…`, which `include_str!` can't read.
|
|
Some(dir) => match locales(&std::path::PathBuf::from(dir).join("locales")) {
|
|
Ok(entries) => entries,
|
|
Err(e) => {
|
|
let level = if required { "error" } else { "warning" };
|
|
println!("cargo::{level}=CRAFT_I18N_DIR: {e}; the interface is English only");
|
|
Vec::new()
|
|
}
|
|
},
|
|
None => {
|
|
if required {
|
|
println!("cargo::error=CRAFT_I18N_REQUIRED=1 but CRAFT_I18N_DIR is unset: a release must embed the translations");
|
|
}
|
|
Vec::new()
|
|
}
|
|
};
|
|
let mut o = String::from("/// (locale, shared catalog, PdfCraft's catalog), one entry per locale; `\"\"` for a missing file.\n");
|
|
o.push_str("pub static CRAFT_I18N: &craft_i18n::Embedded = &[\n");
|
|
for (code, shared, app) in entries {
|
|
// `{:?}` escapes the path (Windows backslashes) as a Rust string literal.
|
|
let shared = shared.map_or_else(|| "\"\"".to_string(), |p| format!("include_str!({p:?})"));
|
|
let _ = writeln!(o, " ({code:?}, {shared}, include_str!({app:?})),");
|
|
}
|
|
o.push_str("];\n");
|
|
o
|
|
}
|
|
|
|
/// One embedded locale: its code, its `common.tsv` if it has one, and its PdfCraft catalog.
|
|
type Locale = (String, Option<std::path::PathBuf>, std::path::PathBuf);
|
|
|
|
/// Every locale under `dir` with PdfCraft's catalog, by code. An error when `dir` isn't a
|
|
/// craft-i18n `locales/` directory.
|
|
fn locales(dir: &std::path::Path) -> Result<Vec<Locale>, String> {
|
|
// Build scripts run in the crate's directory and `include_str!` resolves against the
|
|
// generated file, so a relative path would mean two different places.
|
|
if !dir.is_absolute() {
|
|
return Err(format!("{} is not an absolute path", dir.display()));
|
|
}
|
|
// Before read_dir, so a checkout that appears later reruns the script.
|
|
println!("cargo::rerun-if-changed={}", dir.display());
|
|
let list = std::fs::read_dir(dir).map_err(|e| format!("{} is not a craft-i18n checkout ({e})", dir.display()))?;
|
|
let mut out = Vec::new();
|
|
for entry in list.flatten() {
|
|
let path = entry.path();
|
|
let Some(code) = path.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
|
|
let app = path.join(APP_CATALOG);
|
|
if !app.is_file() {
|
|
continue;
|
|
}
|
|
println!("cargo::rerun-if-changed={}", app.display());
|
|
let shared = Some(path.join("common.tsv")).filter(|p| p.is_file());
|
|
if let Some(p) = &shared {
|
|
println!("cargo::rerun-if-changed={}", p.display());
|
|
}
|
|
out.push((code, shared, app));
|
|
}
|
|
if out.is_empty() {
|
|
return Err(format!("{} has no locale with a {APP_CATALOG}", dir.display()));
|
|
}
|
|
out.sort();
|
|
Ok(out)
|
|
}
|
|
|
|
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
|
println!("cargo::rerun-if-changed={}", path.display());
|
|
let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t));
|
|
if let Err(e) = &r {
|
|
println!("cargo::warning={}: {e}; {fallback}", path.display());
|
|
}
|
|
r.ok()
|
|
}
|
|
|
|
/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal.
|
|
fn name(v: Option<&str>) -> String {
|
|
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
|
Some(x) => format!("Some({x:?})"),
|
|
None => "None".into(),
|
|
}
|
|
}
|
|
|
|
fn s(v: &Value, k: &str) -> String {
|
|
format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or(""))
|
|
}
|
|
|
|
fn n(v: &Value, k: &str) -> u64 {
|
|
v.get(k).and_then(Value::as_u64).unwrap_or(0)
|
|
}
|
|
|
|
fn rows(json: &Value, k: &str) -> Vec<Value> {
|
|
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
|
|
}
|
|
|
|
fn generate(json: &Value, people: &toml::Table) -> String {
|
|
let mut o = String::new();
|
|
let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits"));
|
|
let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &[");
|
|
for c in rows(json, "contributors") {
|
|
let login = c.get("login").and_then(Value::as_str).unwrap_or("");
|
|
if login.is_empty() {
|
|
continue;
|
|
}
|
|
// Names only from a verified, self-chosen people.toml entry.
|
|
let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true));
|
|
let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str);
|
|
let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true);
|
|
let display = field("display_name")
|
|
.filter(|s| !s.trim().is_empty())
|
|
.or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None });
|
|
let _ = writeln!(
|
|
o,
|
|
" Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},",
|
|
name(display),
|
|
name(field("real_name")),
|
|
n(&c, "prs"),
|
|
n(&c, "commits"),
|
|
n(&c, "lines_added"),
|
|
n(&c, "lines_deleted"),
|
|
n(&c, "binary_added"),
|
|
n(&c, "binary_deleted"),
|
|
s(&c, "first_commit"),
|
|
s(&c, "last_commit"),
|
|
);
|
|
}
|
|
let _ = writeln!(o, "];");
|
|
let _ = writeln!(o, "pub static MODELS: &[Model] = &[");
|
|
for m in rows(json, "models") {
|
|
let _ = writeln!(
|
|
o,
|
|
" Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},",
|
|
s(&m, "company"),
|
|
s(&m, "model"),
|
|
s(&m, "version"),
|
|
n(&m, "commits"),
|
|
n(&m, "lines_added"),
|
|
n(&m, "lines_deleted"),
|
|
);
|
|
}
|
|
let _ = writeln!(o, "];");
|
|
o
|
|
}
|