i18n: add Brazilian Portuguese (pt-br) UI translation (#165)

* M14: translation catalogs, same system as PhotoCraft

Move the interface translations to PhotoCraft's i18n system: one TSV
catalog per language (crates/ui-egui/src/i18n/ja.tsv), the tl! macro,
entries keyed by command id, context or plural forms, and catalog tests
(format, duplicates, placeholders, command ids).

The 49 existing Japanese strings move over unchanged, plus "Auto". The
language preference now defaults to "auto" and follows the system
language (English when it has no catalog); "en" and "ja" still persist
and unknown values are rejected.

Update docs/localization.md, ROADMAP.md and the misc.prefs-language
parity entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* i18n: add Brazilian Portuguese (pt-br) UI translation

Adds Language::PtBr (code pt-br, endonym Português (Brasil)) with serde rename so it persists as "pt-br", registers it in Language::ALL (Preferences ▸ Interface language and ui.set/ui.state) and translates the same 49 menu/command keys as the Japanese table. Extends the i18n tests (table parity, codes, persistence) and updates docs/localization.md.

* M14: strict catalog validation, one-pass fmt, translation assets

Fold in the infrastructure parts of #175:
- `fmt` fills placeholders in one pass, so inserted values (a file name
  containing `{n}`) are never substituted again.
- Catalog validation rejects unknown escapes, unknown `@` contexts,
  placeholder, ellipsis and plural-form mismatches and duplicates. The
  bundled catalogs must have no errors (tests); at run time a bad line is
  still only skipped and logged, so the rest of the catalog loads.
- `.tsv` files count as assets in `cargo xtask assets`, with a
  `translation` kind that may never come from Adobe; ja.tsv gets its
  ATTRIBUTION.toml entry.
- Control-channel test: switching the language keeps documents, their
  dirty state and command ids.

Also make the drawing language thread-local, as in PhotoCraft, so
parallel kittest harnesses can't change each other's language.

Co-Authored-By: 才倾梦华V <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M14: Brazilian Portuguese as a TSV catalog

Move #165's Portuguese table into crates/ui-egui/src/i18n/pt-br.tsv
(same 49 strings, plus Auto) and register it in LANGUAGES with
PhotoCraft's Portuguese plural rule (0 and 1 singular). Any Portuguese
locale (pt_BR, pt_PT, pt) follows the Brazilian catalog, as in
PhotoCraft. The catalog is an attributed asset (ATTRIBUTION.toml), and
the docs and parity notes list the new code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Simon Leblanc <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: 才倾梦华V <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-07 22:15:26 -04:00
1 parent a07243c8ff
commit 41734376be
22 files changed
+179 -969

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
# actionlint: runner labels that exist in the storytold org but not on GitHub-hosted runners.
self-hosted-runner:
labels:
- macos-release
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
runs-on: macos-release # org larger runner (M2 Pro), release.yml@release only; capped by the actions_macos_xl budget
environment: release
env:
PDFCRAFT_VERSION: ${{ needs.version.outputs.version }}
-19
View File
@@ -116,23 +116,4 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
## 5. Everything else
Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to run and look at the app.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
+2 -1
View File
@@ -4,10 +4,11 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (243)
## In this repository (244)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+11
View File
@@ -10,6 +10,17 @@
# licence: SPDX expression from the allowlist in AGENTS.md §1.2. adobe_data = true marks the closed list of
# Adobe-authored non-visual technical data allowed by AGENTS.md §1.1.
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
title = "Brazilian Portuguese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0ee4d2f630623ef7e06413a7c34a4a0269a2600fba682f06988a1346be4d07a5"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
title = "Japanese interface translation catalog"
Generated
-1
View File
@@ -3776,7 +3776,6 @@ dependencies = [
"rfd",
"serde",
"serde_json",
"toml",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
-4
View File
@@ -102,8 +102,6 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 20% | 160–305 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, measure, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 0% | 120–250 | |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools`, UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 2% | 118–245 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, catalog tests) with English and the first Japanese strings (menus, Preferences). Missing: dialogs and panels through `tl!`, more languages, Windows language detection, installers, performance budgets |
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
@@ -129,8 +127,6 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
Newest first. One line per session: the date, what moved, and the new overall percentage.
- **2026-10-08 (community, #135):** CLI output exits cleanly when a pipe reader closes early. Other stdout errors return a failure instead of panicking, and command/file errors keep their diagnostics. Synthetic subprocess regressions cover closed pipes and a reader stopping after one line. Parity counts and effort estimate unchanged (≈ 30–35%).
- **2026-10-08 (community):** Cut and stack in Print > Multiple: single-sided n-up sheets can be cut into cell piles and restacked in selected page order, with aligned gutter marks and blanks kept in their cells. Available through doc_print; duplex is refused. Grid-size overflow returns an error. No change to the parity count or the overall estimate (about 30–35%).
- **2026-10-07 (community, #143):** Windows MSI gets a full-UI welcome dialog with a "Create a desktop shortcut" checkbox (on by default; `INSTALLDESKTOPSHORTCUT=0` for unattended installs), progress, files in use, repair/remove, and a "Setup completed successfully" screen with Finish (plus distinct failed/cancelled screens). Start Menu and desktop shortcuts are now plain links to `pdfcraft.exe` instead of advertised MSI shortcuts. Packaging checks the compiled MSI's shortcut, checkbox and dialog tables; the ARM64 smoke test checks both links' targets, their removal on uninstall, and the opt-out. Installer polish only; feature counts unchanged. ≈ 30–35%.
- **2026-10-07 (localization):** The interface translates through the same system as PhotoCraft: one TSV catalog per language (`crates/ui-egui/src/i18n/ja.tsv`), the `tl!` macro, entries keyed by command id or with plural forms, and catalog tests (format, duplicates, placeholders, command ids). Catalogs are validated strictly (unknown escapes, reserved contexts, placeholders, ellipses, plural forms), a bad line is skipped and logged at run time, `fmt` substitutes in one pass, the drawing language is per thread, and each `.tsv` is an attributed asset (`kind = "translation"`, checked by `cargo xtask assets`); validation, attribution and the language-switch control test folded in from #175. The 49 existing Japanese strings moved over unchanged, plus `Auto`. The `language` preference now defaults to `auto` (follow the system language; English when it has no catalog); `en`/`ja` still persist, and unknown values are rejected. Not covered: dialogs and panels, Windows system-language detection. ≈ 30–35% (unchanged).
+40 -79
View File
@@ -59,61 +59,25 @@ fn main() -> ExitCode {
Some("ui") => ui(&args[1..]),
#[cfg(feature = "mcp")]
Some("mcp") => mcp(&args[1..]),
Some("--version") => version(),
Some("--version") => {
println!("pdfcraft-cli {}", env!("CARGO_PKG_VERSION"));
println!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD);
println!("Web: {}", pdfcraft_engine::links::APP_PAGE);
println!("Source: {}", pdfcraft_engine::links::GITHUB);
Ok(())
}
_ => Err("usage: pdfcraft-cli <info|render|text|edit|combine|extract|split|check|tools|run|mcp|ui> … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft"
.into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(CliError::Stdout(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
Err(e) => {
let _ = writeln!(std::io::stderr().lock(), "pdfcraft-cli: {e}");
eprintln!("pdfcraft-cli: {e}");
ExitCode::FAILURE
}
}
}
// Keep stdout errors typed: a closed pipe is normal, but file and command errors still fail.
#[derive(Debug)]
enum CliError {
Message(String),
Stdout(std::io::Error),
}
impl From<String> for CliError {
fn from(message: String) -> Self {
Self::Message(message)
}
}
impl From<&str> for CliError {
fn from(message: &str) -> Self {
Self::Message(message.to_string())
}
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Message(message) => f.write_str(message),
Self::Stdout(error) => write!(f, "stdout: {error}"),
}
}
}
fn stdout_line(line: std::fmt::Arguments<'_>) -> Result<(), CliError> {
let mut stdout = std::io::stdout().lock();
writeln!(stdout, "{line}").and_then(|()| stdout.flush()).map_err(CliError::Stdout)
}
fn version() -> Result<(), CliError> {
stdout_line(format_args!("pdfcraft-cli {}", env!("CARGO_PKG_VERSION")))?;
stdout_line(format_args!("Discord: {} (help and feedback)", pdfcraft_engine::links::DISCORD))?;
stdout_line(format_args!("Web: {}", pdfcraft_engine::links::APP_PAGE))?;
stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?;
Ok(())
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
@@ -139,7 +103,7 @@ fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
}
fn info(args: &[String]) -> Result<(), CliError> {
fn info(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("info: missing file")?;
let info = inspect(read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
let json = serde_json::json!({
@@ -156,11 +120,11 @@ fn info(args: &[String]) -> Result<(), CliError> {
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
"warnings": info.warnings,
});
stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
println!("{}", serde_json::to_string_pretty(&json).unwrap_or_default());
Ok(())
}
fn text(args: &[String]) -> Result<(), CliError> {
fn text(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("text: missing file")?;
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
let pages: Vec<usize> = match flag(args, "--page") {
@@ -176,14 +140,14 @@ fn text(args: &[String]) -> Result<(), CliError> {
continue;
}
if n > 0 {
stdout_line(format_args!("\u{c}"))?;
println!("\u{c}");
}
stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?;
println!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default());
}
if failed.is_empty() {
Ok(())
} else {
Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")).into())
Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")))
}
}
@@ -193,7 +157,7 @@ fn page_list(s: &str) -> Result<Vec<usize>, String> {
}
/// Apply page and metadata edits through the engine and save (incrementally unless `--full`).
fn edit(args: &[String]) -> Result<(), CliError> {
fn edit(args: &[String]) -> Result<(), String> {
use pdfcraft_engine::{Edit, Session};
let path = *positional(args).first().ok_or("edit: missing file")?;
let out = flag(args, "--out").ok_or("edit: missing --out")?;
@@ -228,14 +192,14 @@ fn edit(args: &[String]) -> Result<(), CliError> {
session.apply(id, e).map_err(|e| e.to_string())?;
}
let bytes = if args.iter().any(|a| a == "--full") { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
}
fn file_stem(path: &str) -> String {
Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
}
fn combine(args: &[String]) -> Result<(), CliError> {
fn combine(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out").ok_or("combine: missing --out")?;
let inputs = positional(args);
if inputs.len() < 2 {
@@ -243,20 +207,20 @@ fn combine(args: &[String]) -> Result<(), CliError> {
}
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::<Result<Vec<_>, String>>()?;
let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
}
fn extract(args: &[String]) -> Result<(), CliError> {
fn extract(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("extract: missing file")?;
let out = flag(args, "--out").ok_or("extract: missing --out")?;
let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?;
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}"))
}
fn split(args: &[String]) -> Result<(), CliError> {
fn split(args: &[String]) -> Result<(), String> {
use pdfcraft_engine::SplitBy;
let path = *positional(args).first().ok_or("split: missing file")?;
let by = match (flag(args, "--every"), flag(args, "--before")) {
@@ -271,12 +235,12 @@ fn split(args: &[String]) -> Result<(), CliError> {
for (a, b, bytes) in session.split(id, &by).map_err(|e| e.to_string())? {
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?;
stdout_line(format_args!("{}", name.display()))?;
println!("{}", name.display());
}
Ok(())
}
fn render(args: &[String]) -> Result<(), CliError> {
fn render(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
@@ -284,7 +248,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = p.error {
return Err(e.into());
return Err(e);
}
// PAM (netpbm RGB_ALPHA) keeps this tool dependency-free; convert with any image tool.
let mut f = std::fs::File::create(out).map_err(|e| e.to_string())?;
@@ -295,7 +259,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
}
/// Child-process body for `check`: prints one JSON line.
fn check_one(args: &[String]) -> Result<(), CliError> {
fn check_one(args: &[String]) -> Result<(), String> {
let path = *positional(args).first().ok_or("check-one: missing file")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
let start = Instant::now();
@@ -325,7 +289,7 @@ fn check_one(args: &[String]) -> Result<(), CliError> {
}
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
stdout_line(format_args!("{line}"))?;
println!("{line}");
Ok(())
}
@@ -375,7 +339,7 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
out
}
fn check(args: &[String]) -> Result<(), CliError> {
fn check(args: &[String]) -> Result<(), String> {
let files = collect(&positional(args));
if files.is_empty() {
return Err("check: no PDF files found".into());
@@ -449,37 +413,37 @@ fn automation(args: &[String]) -> Result<pdfcraft_automation::Automation, String
}
}
fn tools() -> Result<(), CliError> {
fn tools() -> Result<(), String> {
let list: Vec<serde_json::Value> = pdfcraft_automation::tools()
.iter()
.map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema }))
.collect();
stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?;
println!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
Ok(())
}
/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is
/// written like a tool's own output, so `--root` confines it too.
fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_automation::Content>, out: Option<&str>) -> Result<(), CliError> {
fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_automation::Content>, out: Option<&str>) -> Result<(), String> {
for c in content {
match c {
pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?,
pdfcraft_automation::Content::Json(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()),
pdfcraft_automation::Content::Png { data, width, height } => match out {
Some(path) => {
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
println!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }));
}
None => stdout_line(format_args!(
None => println!(
"{}",
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
))?,
),
},
}
}
Ok(())
}
fn run(args: &[String]) -> Result<(), CliError> {
fn run(args: &[String]) -> Result<(), String> {
let mut auto = automation(args)?;
if let Some(script) = flag(args, "--script") {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
@@ -487,10 +451,7 @@ fn run(args: &[String]) -> Result<(), CliError> {
for (i, step) in steps.iter().enumerate() {
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
print_output(&auto, content, step["out"].as_str()).map_err(|e| match e {
CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)),
stdout => stdout,
})?;
print_output(&auto, content, step["out"].as_str()).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
}
return Ok(());
}
@@ -506,16 +467,16 @@ fn run(args: &[String]) -> Result<(), CliError> {
}
#[cfg(feature = "mcp")]
fn mcp(args: &[String]) -> Result<(), CliError> {
fn mcp(args: &[String]) -> Result<(), String> {
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?);
eprintln!("pdfcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0]);
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string()))
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| e.to_string())
}
// ---- UI control channel client -----------------------------------------------------------------
/// One request to a running app's control channel (`pdfcraft --control FILE`).
fn ui(args: &[String]) -> Result<(), CliError> {
fn ui(args: &[String]) -> Result<(), String> {
use std::io::{BufRead, BufReader, Write as _};
let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
let info: serde_json::Value =
@@ -552,6 +513,6 @@ fn ui(args: &[String]) -> Result<(), CliError> {
std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
result["png_base64"] = serde_json::json!(format!("written to {out}"));
}
stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?;
println!("{}", serde_json::to_string_pretty(&result).unwrap_or_default());
Ok(())
}
-84
View File
@@ -1,84 +0,0 @@
//! A closed output pipe is normal when a consumer such as `head` exits early (#135).
use std::io::{Read, pipe};
use std::process::{Command, Output, Stdio};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn closed_stdout(args: &[&str]) -> Output {
let (reader, writer) = pipe().unwrap();
drop(reader);
Command::new(BIN).args(args).stdout(writer).stderr(Stdio::piped()).spawn().unwrap().wait_with_output().unwrap()
}
#[test]
fn output_commands_exit_cleanly_when_the_reader_has_closed() {
let base = std::env::temp_dir().join(format!("pdfcraft-cli-stdout-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let pdf = base.join("text.pdf");
let bytes = pdfcraft_engine::Session::new().create_from_text("Pipe test", "Hello from PdfCraft").unwrap();
std::fs::write(&pdf, bytes.as_slice()).unwrap();
let path = pdf.to_str().unwrap();
let script = base.join("steps.json");
let saved = base.join("should-not-be-written.pdf");
let steps = serde_json::json!([
{ "tool": "doc_create", "args": { "from": "blank" } },
{ "tool": "doc_save", "args": { "doc": 1, "path": saved } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
for args in [
vec!["tools"],
vec!["--version"],
vec!["info", path],
vec!["text", path],
vec!["check-one", path],
vec!["run", "command_list"],
vec!["run", "--script", script.to_str().unwrap()],
vec!["split", path, "--every", "1", "--out-dir", base.to_str().unwrap()],
] {
let out = closed_stdout(&args);
assert!(out.status.success(), "{args:?}: {}: {}", out.status, String::from_utf8_lossy(&out.stderr));
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
assert!(!saved.exists(), "a closed output pipe stops the script before the next edit");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn tools_exits_cleanly_when_the_reader_stops_after_one_line() {
let mut child = Command::new(BIN).arg("tools").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut stdout = child.stdout.take().unwrap();
// Read through the first newline without buffering the rest, then close the pipe.
let mut byte = [0];
while stdout.read_exact(&mut byte).is_ok() && byte[0] != b'\n' {}
drop(stdout);
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "{}: {}", out.status, String::from_utf8_lossy(&out.stderr));
assert!(out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr));
}
#[test]
fn a_closed_stdout_does_not_hide_command_errors() {
let out = closed_stdout(&["info"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("info: missing file"));
}
#[cfg(target_os = "linux")]
#[test]
fn other_stdout_errors_fail_without_panicking() {
let full = std::fs::OpenOptions::new().write(true).open("/dev/full").unwrap();
let out = Command::new(BIN).arg("--version").stdout(full).stderr(Stdio::piped()).spawn().unwrap().wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("stdout") && !stderr.contains("panicked"), "{stderr}");
}
#[test]
fn ordinary_output_keeps_its_format() {
let out = Command::new(BIN).arg("tools").output().unwrap();
assert!(out.status.success() && out.stderr.is_empty());
let tools: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(tools.as_array().unwrap().iter().any(|t| t["name"] == "doc_open"));
assert!(out.stdout.ends_with(b"\n"));
}
-114
View File
@@ -1,114 +0,0 @@
{
"schema": 1,
"repo": "storytold/pdfcraft",
"branch": "main",
"head": "d3db99818f533a531dbcd469b2ac71ba35c0d3ec",
"head_date": "2026-10-07T20:03:57Z",
"commits": 252,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 50,
"commits": 229,
"lines_added": 218049,
"lines_deleted": 10047,
"binary_added": 81,
"binary_deleted": 25,
"first_commit": "2026-09-30T09:17:08Z",
"last_commit": "2026-10-07T20:03:57Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 8,
"commits": 8,
"lines_added": 350,
"lines_deleted": 33,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T04:14:56Z",
"last_commit": "2026-10-05T20:36:08Z"
},
{
"login": "justinjohn0306",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 1,
"lines_added": 1059,
"lines_deleted": 92,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-05T11:48:23Z",
"last_commit": "2026-10-05T11:48:23Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 174,
"lines_deleted": 16,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T21:10:09Z",
"last_commit": "2026-10-05T21:10:09Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 10,
"commits": 13,
"lines_added": 1053,
"lines_deleted": 260,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T18:21:42Z",
"last_commit": "2026-10-07T02:52:19Z"
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 239,
"lines_added": 158990,
"lines_deleted": 9809
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 1,
"lines_added": 1632,
"lines_deleted": 3
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 1,
"lines_added": 13,
"lines_deleted": 1
},
{
"id": "zhipu/glm",
"company": "Zhipu AI",
"model": "GLM",
"version": "",
"commits": 1,
"lines_added": 1059,
"lines_deleted": 92
}
]
}
-23
View File
@@ -1,23 +0,0 @@
# Contributor names for the About window (craftrules standards/contributors.md).
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
# display_name = "Nickname" # shown when the viewer picks "Display name"
# sync_github_name = true # or: use the name on your public GitHub profile as display name
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
-4
View File
@@ -42,7 +42,3 @@ pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
image = { version = "0.25", default-features = false, features = ["png"] }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
-105
View File
@@ -1,105 +0,0 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it 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.
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());
}
}
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
}
-405
View File
@@ -1,405 +0,0 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
//! read at run time.
use std::cmp::Ordering;
use egui::RichText;
use crate::theme::Tokens;
/// The About dialog is a modal that sizes to its content; the lists scroll inside this height.
const LIST_HEIGHT: f32 = 380.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Contributor {
pub login: &'static str,
pub display_name: Option<&'static str>,
pub real_name: Option<&'static str>,
pub prs: u64,
pub commits: u64,
pub lines_added: u64,
pub lines_deleted: u64,
pub binary_added: u64,
pub binary_deleted: u64,
/// ISO 8601 UTC; empty for someone credited only through a merged PR.
pub first_commit: &'static str,
pub last_commit: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Model {
pub company: &'static str,
pub model: &'static str,
pub version: &'static str,
pub commits: u64,
pub lines_added: u64,
pub lines_deleted: u64,
}
include!(concat!(env!("OUT_DIR"), "/credits.rs"));
/// Which name to show. Display and real names fall back to the `@username` when not given.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NameMode {
#[default]
Username,
DisplayName,
RealName,
}
impl NameMode {
pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName];
pub fn label(self) -> &'static str {
match self {
NameMode::Username => "Username",
NameMode::DisplayName => "Display name",
NameMode::RealName => "Real name",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SortKey {
Name,
Prs,
Commits,
LinesAdded,
LinesDeleted,
LinesDelta,
BinaryAdded,
BinaryDeleted,
#[default]
FirstCommit,
LastCommit,
}
impl SortKey {
pub const ALL: [SortKey; 10] = [
SortKey::Name,
SortKey::Prs,
SortKey::Commits,
SortKey::LinesAdded,
SortKey::LinesDeleted,
SortKey::LinesDelta,
SortKey::BinaryAdded,
SortKey::BinaryDeleted,
SortKey::FirstCommit,
SortKey::LastCommit,
];
/// Menu label and table header.
pub fn label(self) -> (&'static str, &'static str) {
match self {
SortKey::Name => ("Name (A–Z)", "Name"),
SortKey::Prs => ("Merged PRs", "PRs"),
SortKey::Commits => ("Commits", "Commits"),
SortKey::LinesAdded => ("Lines added", "+LOC"),
SortKey::LinesDeleted => ("Lines deleted", "−LOC"),
SortKey::LinesDelta => ("Line delta", "ΔLOC"),
SortKey::BinaryAdded => ("Binary assets added", "+Bin"),
SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"),
SortKey::FirstCommit => ("First commit", "First"),
SortKey::LastCommit => ("Last commit", "Last"),
}
}
/// Names and dates read naturally oldest/A first; counts read biggest first.
pub fn default_ascending(self) -> bool {
matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit)
}
}
impl Contributor {
pub fn name(&self, mode: NameMode) -> String {
let chosen = match mode {
NameMode::Username => None,
NameMode::DisplayName => self.display_name,
NameMode::RealName => self.real_name,
};
chosen.map_or_else(|| format!("@{}", self.login), str::to_string)
}
pub fn lines_delta(&self) -> i128 {
i128::from(self.lines_added) - i128::from(self.lines_deleted)
}
/// One line with everything we know, for tooltips.
pub fn summary(&self) -> String {
format!(
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
self.login,
self.prs,
self.commits,
group(self.lines_added),
group(self.lines_deleted),
signed(self.lines_delta()),
self.binary_added,
self.binary_deleted,
day(self.first_commit),
day(self.last_commit),
)
}
}
/// Alphabetical key: case-insensitive, ignoring the `@` of usernames.
fn name_key(name: &str) -> String {
name.trim_start_matches('@').to_lowercase()
}
fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering {
match key {
SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))),
SortKey::Prs => a.prs.cmp(&b.prs),
SortKey::Commits => a.commits.cmp(&b.commits),
SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added),
SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted),
SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()),
SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added),
SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted),
// A PR-only contributor (no commit date) sorts after everyone with a date.
SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)),
SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)),
}
}
fn dated(d: &str) -> (bool, &str) {
(d.is_empty(), d)
}
/// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable.
pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> {
let mut v: Vec<&Contributor> = list.iter().collect();
v.sort_by(|a, b| {
let o = compare(a, b, mode, key);
let o = if ascending { o } else { o.reverse() };
o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login))
});
v
}
fn group(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
out
}
fn signed(n: i128) -> String {
let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX));
if n < 0 { format!("−{g}") } else { format!("+{g}") }
}
fn day(iso: &str) -> &str {
if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct View {
names: NameMode,
key: SortKey,
ascending: bool,
table: bool,
}
impl Default for View {
fn default() -> Self {
View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false }
}
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
v.names = m;
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
v.ascending = !v.ascending;
}
ui.separator();
if ui.selectable_label(!v.table, "Grab bag").clicked() {
v.table = false;
}
if ui.selectable_label(v.table, "Table").clicked() {
v.table = true;
}
});
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_muted));
ui.separator();
egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").color(t.text_faint));
}
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
});
}
#[cfg(test)]
mod tests {
use super::*;
fn c(
login: &'static str,
display: Option<&'static str>,
real: Option<&'static str>,
added: u64,
deleted: u64,
first: &'static str,
) -> Contributor {
Contributor {
login,
display_name: display,
real_name: real,
prs: 0,
commits: 1,
lines_added: added,
lines_deleted: deleted,
binary_added: 0,
binary_deleted: 0,
first_commit: first,
last_commit: first,
}
}
fn people() -> [Contributor; 4] {
[
c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"),
c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"),
c("alice", None, None, 7, 1, ""),
c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"),
]
}
fn logins(v: &[&Contributor]) -> Vec<&'static str> {
v.iter().map(|c| c.login).collect()
}
#[test]
fn names_fall_back_to_the_username() {
let p = c("bob", Some("Bobby"), None, 0, 0, "");
assert_eq!(p.name(NameMode::Username), "@bob");
assert_eq!(p.name(NameMode::DisplayName), "Bobby");
assert_eq!(p.name(NameMode::RealName), "@bob");
}
#[test]
fn alphabetical_ignores_at_and_case() {
let all = people();
let v = sorted(&all, NameMode::Username, SortKey::Name, true);
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]);
let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true);
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames
let v = sorted(&all, NameMode::RealName, SortKey::Name, false);
assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed
}
#[test]
fn delta_and_dates() {
let all = people();
let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false);
assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]);
let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true);
assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last
}
#[test]
fn formatting() {
assert_eq!(group(1234567), "1,234,567");
assert_eq!(group(12), "12");
assert_eq!(signed(-1000), "−1,000");
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
}
+23 -43
View File
@@ -1,4 +1,4 @@
//! Modal dialogs: Document Properties, Keyboard Shortcuts, About (with the Contributors and Models credits).
//! Modal dialogs: Document Properties, Keyboard Shortcuts, About.
use egui::{Align, Layout};
@@ -65,7 +65,6 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Properties(_) => 640.0,
Dialog::Print => 820.0,
Dialog::FieldProps => 600.0,
Dialog::About => 780.0,
_ => 520.0,
});
// Dialog controls are outlined (radio buttons, check boxes, combo boxes and number fields
@@ -1014,49 +1013,30 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
});
}
Dialog::About => {
// Tabs About · Contributors · Models (craftrules standards/contributors.md).
let tab_id = egui::Id::new("about_tab");
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() {
let i = i as u8;
if widgets::mode_tab(ui, label, tab == i).clicked() {
tab = i;
}
}
widgets::artcraft_mark(ui, 40.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0)));
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
});
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.separator();
match tab {
1 => crate::credits::contributors_ui(ui),
2 => crate::credits::models_ui(ui),
_ => {
ui.horizontal(|ui| {
widgets::artcraft_mark(ui, 40.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0)));
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
});
});
ui.add_space(6.0);
ui.label("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.");
ui.label(
egui::RichText::new(
"Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)",
)
.color(t.text_muted)
.small(),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Part of").color(t.text_muted));
widgets::artcraft_logo(ui, 16.0);
});
ui.add_space(6.0);
if let Some(cmd) = widgets::community_links(ui) {
link_command = Some(cmd);
}
}
ui.add_space(6.0);
ui.label("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.");
ui.label(
egui::RichText::new(
"Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)",
)
.color(t.text_muted)
.small(),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Part of").color(t.text_muted));
widgets::artcraft_logo(ui, 16.0);
});
ui.add_space(6.0);
if let Some(cmd) = widgets::community_links(ui) {
link_command = Some(cmd);
}
}
}
+35 -1
View File
@@ -55,10 +55,17 @@ fn plural_none(_: u64) -> usize {
0
}
/// Portuguese: 0 and 1 take the singular, everything else the plural.
fn plural_pt(n: u64) -> usize {
usize::from(n > 1)
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 2] = [
pub static LANGUAGES: [LangInfo; 3] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, catalog: OnceLock::new() },
// Brazilian Portuguese; `pt`, `pt-BR` and `pt-PT` locales all resolve here (see `candidates`).
LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), plural: plural_pt, catalog: OnceLock::new() },
];
impl LangInfo {
@@ -152,6 +159,10 @@ fn candidates(tag: &str) -> Vec<String> {
let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" };
out.insert(out.len().saturating_sub(1), script.to_string());
}
if primary == "pt" && !out.iter().any(|c| c == "pt-br") {
// The only Portuguese catalog is Brazilian; other regions use it rather than English.
out.insert(out.len().saturating_sub(1), "pt-br".to_string());
}
out
}
@@ -303,6 +314,8 @@ mod tests {
#[test]
fn candidates_walk_from_specific_to_general() {
assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]);
assert_eq!(candidates("pt_PT.UTF-8"), ["pt-pt", "pt-br", "pt"]);
assert_eq!(candidates("pt"), ["pt-br", "pt"]);
assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]);
assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]);
assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]);
@@ -480,6 +493,27 @@ mod tests {
assert_eq!(legacy.language, AUTO);
}
#[test]
fn brazilian_portuguese_is_registered() {
let pt = Lang::from_code("pt-br").expect("pt-br registered");
assert_eq!(pt.name(), "Português (Brasil)");
assert_eq!(Lang::from_code("PT-BR"), Some(pt));
assert_eq!(normalize_pref("pt-BR"), Some("pt-br"));
assert_eq!(normalize_pref("pt"), None, "only exact codes are preferences");
assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(pt));
assert_eq!(lang_from_tag("pt_PT"), Some(pt));
assert_eq!(tr(pt, "File"), "Arquivo");
assert_eq!(tr(pt, "Save as…"), "Salvar como…");
assert_eq!(tr(pt, "Arquivo do usuário.pdf"), "Arquivo do usuário.pdf");
assert_eq!((0..=3).map(|n| (pt.0.plural)(n)).collect::<Vec<_>>(), [0, 0, 1, 1]);
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "pt-br").unwrap();
assert_eq!(app.language, "pt-br");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "pt-br");
}
/// Every bundled catalog is well-formed and consistent with its sources.
#[test]
fn bundled_catalogs_are_consistent() {
+60
View File
@@ -0,0 +1,60 @@
# Brazilian Portuguese (pt-br) catalog. Format: see the header of ja.tsv
# (context <TAB> source <TAB> translation; @id, @plural and other contexts; escapes \n \t \\).
# Plural forms (see `plural_pt` in i18n/mod.rs): one (0 and 1) | other.
#
# Clean-room: every entry was written from the plain meaning of the English label using ordinary
# Brazilian Portuguese computing vocabulary. No proprietary localisation resource, string table or
# product UI (Acrobat's included) was consulted or copied; add entries the same way.
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
Menu Menu
File Arquivo
Edit Editar
Pages Páginas
View Exibir
Help Ajuda
Preferences Preferências
Preferences… Preferências…
Interface language Idioma da interface
Identity Identidade
Name on new comments Nome nos novos comentários
Open… Abrir…
New blank PDF Novo PDF em branco
Create PDF from file… Criar PDF a partir de arquivo…
Create PDF from images… Criar PDF a partir de imagens…
Create PDF from clipboard Criar PDF a partir da área de transferência
Combine files… Combinar arquivos…
Save Salvar
Save as… Salvar como…
Close file Fechar arquivo
Close all Fechar tudo
Revert Reverter
Print… Imprimir…
Document properties… Propriedades do documento…
Undo Desfazer
Redo Refazer
Find… Localizar…
Advanced search… Pesquisa avançada…
Copy pages Copiar páginas
Cut pages Recortar páginas
Paste pages Colar páginas
Fit visible Ajustar ao visível
Marquee zoom Zoom por seleção
Take a snapshot Capturar instantâneo
Full screen mode Modo de tela cheia
Read mode Modo de leitura
Switch light / dark theme Alternar tema claro / escuro
Comments panel Painel de comentários
Form fields panel Painel de campos de formulário
Clear form Limpar formulário
Find tools and commands… Localizar ferramentas e comandos…
Zoom Zoom
Actual size Tamanho real
Zoom to page level Ajustar à página
Fit to width Ajustar à largura
Display theme Tema de exibição
Side panels Painéis laterais
Enable Acrobat JavaScript Ativar o Acrobat JavaScript
OK OK
Auto Automático
Can't render this file because it contains an unexpected character in line 9 and column 16.
-1
View File
@@ -26,7 +26,6 @@ mod comments_panel;
mod compare_ui;
pub mod control;
mod create_ui;
mod credits;
mod crop;
mod export_ui;
mod js_ui;
-18
View File
@@ -64,21 +64,3 @@ fn help_commands_open_each_link() {
assert_eq!(pdfcraft_engine::commands::command(l.command).unwrap().menu, Some("Help"));
}
}
#[test]
fn about_dialog_has_contributors_and_models_tabs() {
let mut h = harness(|app| app.dialog = Some(Dialog::About));
h.get_by_label("Contributors").click();
h.run_steps(2);
// The owner is always in the compiled-in credits (contributors/contributors.json), shown by username.
h.get_by_label("@echelon");
h.get_by_label("Table").click();
h.run_steps(2);
h.get_by_label("PRs");
h.get_by_label("Display name").click();
h.run_steps(2);
h.get_by_label("Brandon Thomas");
h.get_by_label("Models").click();
h.run_steps(2);
assert!(h.query_all_by_label("Anthropic").count() >= 1);
}
-55
View File
@@ -1,55 +0,0 @@
# Contributors in the About window
**About ▸ Contributors** credits everyone who contributed to PdfCraft, and **About ▸ Models** credits
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
this page is the local copy of the decision.
## Decision
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
GitHub profile name), real name (only if they told us), merged PRs, commits, lines added, lines
deleted, binary assets added, binary assets removed, first and last commit date.
- The list is shown as a **grab bag** (names flowing as text) or a **table**, in the same order. It
sorts by name, PRs, commits, lines added, lines deleted, line delta, binary assets added/removed,
first or last commit date. The name toggle cycles **Username → Display name → Real name**; a
missing name falls back to `@username`. Alphabetical sorting is case-insensitive and ignores the `@`.
## In PdfCraft
- The About window is the `Dialog::About` arm in `crates/ui-egui/src/dialogs.rs` (Help ▸ About, `help.about`).
## Credit yourself
Add your entry to `contributors/people.toml` in a PR you commit yourself (every field optional):
```toml
[people.your-github-username]
real_name = "Your Name"
display_name = "Nickname"
sync_github_name = true # use the name on your public GitHub profile as display name
```
or, with craftrules checked out next to `craft-apps/`:
```sh
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
```
Names show after the next refresh: the script checks that each entry was committed by its own
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
## Refreshing (maintainers)
```sh
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
```
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
+4 -4
View File
@@ -1,8 +1,8 @@
# Interface language
Choose **Edit > Preferences > Interface language** and select **Auto**, **English** or **日本語**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
Choose **Edit > Preferences > Interface language** and select **Auto**, **English**, **日本語** or **Português (Brasil)**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. A system language without a catalog, such as French, shows English. Windows has no system-language detection yet, so **Auto** shows English there unless `LANG` is set.
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog. A system language without a catalog, such as French, shows English. Windows has no system-language detection yet, so **Auto** shows English there unless `LANG` is set.
The control channel exposes the setting through `ui.set`:
@@ -10,11 +10,11 @@ The control channel exposes the setting through `ui.set`:
{"method":"ui.set","params":{"key":"language","value":"ja"}}
```
The value is `auto` or a language code (`en`, `ja`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en` or `ja`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
The value is `auto` or a language code (`en`, `ja`, `pt-br`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja` or `pt-br`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
This first translation pass covers the main menu and core registered menu commands. Untranslated labels use English. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes.
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Brazilian Portuguese needs only the bundled Latin faces.
## How translations work
+3 -3
View File
@@ -9290,8 +9290,8 @@ milestone = "M13"
status = "shipped"
commands = []
tools = []
evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed"]
notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors."
evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip"]
notes = ""
[[feature]]
id = "misc.cli-completeness"
@@ -9807,7 +9807,7 @@ status = "partial"
commands = []
tools = []
evidence = ["crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids"]
notes = "Preferences switches Auto (system language)/English/Japanese and persists; ui.set language auto|en|ja and ui.state expose it. TSV catalogs per language (same system as PhotoCraft). Core menu labels translated; dialogs, panels, Windows system-language detection and more languages remain open."
notes = "Preferences switches Auto (system language)/English/Japanese/Brazilian Portuguese and persists; ui.set language auto|en|ja|pt-br and ui.state expose it. TSV catalogs per language (same system as PhotoCraft). Core menu labels translated; dialogs, panels, Windows system-language detection and more languages remain open."
[[feature]]
id = "misc.prefs-reading"