From 932967a30df52de4138c30a968300f8a7134a89e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 03:33:35 -0400 Subject: [PATCH] Restore #196, #202, #203 and the macOS release-runner change, reverted by the i18n chain (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Release CI: macOS job on macos-15-xlarge Custom macOS larger runners in a runner group do not pick up jobs; the built-in M2 Pro label does. Only release.yml uses it, and the actions_macos_xl budget caps the monthly spend. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 90d60b0c98e0d7da043d1e9743ec76d946d1e7ea) * CI: tell actionlint about the org's macos-release runner label (#202) Co-authored-by: Claude Opus 5.5 (cherry picked from commit ddea3c72e0f4c4396c4353ff066a3251aee4b4b7) * About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#196) * About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle) Adds About ▸ Contributors and About ▸ Models tabs per the craftrules standard (standards/contributors.md): contributors/contributors.json is generated by craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as static tables; nothing is read at run time. Each contributor line: @username, opt-in display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last commit. Sortable by every column; alphabetical ignores case and the leading @. Co-Authored-By: Claude Opus 5.5 * Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only for entries the generator verified as written by that person (craftrules standards/contributors.md). Co-Authored-By: Claude Opus 5.5 * Contributors: regenerate (verified people.toml entries) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit 41e214c3180358430163d6d661a5aea1b683e6c9) * M13: exit cleanly when stdout pipe closes (#203) Co-authored-by: Brandon Thomas (cherry picked from commit 1cf0d319e47d61821eebc82b550af0421fc2989c) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Ayan <108534604+excited-raccoon@users.noreply.github.com> --- .github/actionlint.yaml | 4 + .github/workflows/release.yml | 2 +- AGENTS.md | 19 ++ Cargo.lock | 1 + ROADMAP.md | 4 +- apps/pdfcraft-cli/src/main.rs | 119 ++++++--- apps/pdfcraft-cli/tests/stdout.rs | 84 +++++++ contributors/contributors.json | 114 +++++++++ contributors/people.toml | 23 ++ crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/build.rs | 105 ++++++++ crates/ui-egui/src/credits.rs | 405 ++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 66 +++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/links.rs | 18 ++ docs/contributors.md | 55 ++++ parity/acrobat-features.toml | 4 +- 17 files changed, 961 insertions(+), 67 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 apps/pdfcraft-cli/tests/stdout.rs create mode 100644 contributors/contributors.json create mode 100644 contributors/people.toml create mode 100644 crates/ui-egui/build.rs create mode 100644 crates/ui-egui/src/credits.rs create mode 100644 docs/contributors.md diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..8c16b6b0 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +# actionlint: runner labels that exist in the storytold org but not on GitHub-hosted runners. +self-hosted-runner: + labels: + - macos-release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 151d8e6d..4dd9f951 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: macos: name: macOS universal needs: version - runs-on: macos-release # org larger runner (M2 Pro), release.yml@release only; capped by the actions_macos_xl budget + runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) environment: release env: PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} diff --git a/AGENTS.md b/AGENTS.md index 0720da26..fd55db70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,4 +116,23 @@ 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.]` 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). diff --git a/Cargo.lock b/Cargo.lock index 581f78b9..f43b4840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3794,6 +3794,7 @@ dependencies = [ "serde_json", "skrifa 0.44.0", "sys-locale", + "toml", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/ROADMAP.md b/ROADMAP.md index 79e5eadc..de7ebfad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,7 +102,7 @@ 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: embedded/72/custom DPI choices for image imports; 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 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, 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`, UI control channel with drag. Missing: Action Wizard, AI providers | +| 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 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese and Simplified Chinese catalogs have ≈1,800 entries each; Simplified Chinese coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. Missing: more catalogs filled in, 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** | | @@ -136,6 +136,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-07 (community, #208):** Measure: distance, perimeter and area annotations (standard LineDimension/PolyLineDimension/PolygonDimension with rectilinear /Measure), persistent viewport scales with two-point calibration, vector snapping (endpoints, midpoints, intersections, paths) through nested forms, live readings, CSV export, and eight `measure_*` agent tools. Hardened on landing: snap extraction caps segments and snap targets page-wide and skips undecodable streams and over-deep `q` instead of failing; unsupported imported measurements (compound ft-in, fractional, invalid geometry) are listed as unsupported rather than failing the list; non-ASCII units ("m²") are allowed and rendered in WinAnsi; a closing duplicate polygon vertex counts as closed. Distance and scale are partial (single-unit decimal formats only; Acrobat interop untested). 51.0% shipped; ≈ 30–35% effort. - **2026-10-07 (community, #190):** Create PDF from Images offers embedded resolution, 72 DPI, or custom DPI (1–1200) without resampling. The engine and doc_create share the choice; Windows image context menus open the chooser through --create-images. Overall estimate unchanged (about 30–35%). +- **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, #161):** The "Save changes?" prompt keeps a long file name inside the modal: the title wraps to the dialog width, so the centered panel no longer stretches past the viewport, clips its message and pushes Save/Cancel off-screen (Windows report). The password prompt's file-name line wraps the same way, and a regression test pins the title and all three buttons inside a 1365×719 screen. Parity counts unchanged; ≈ 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%. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index b3e1ccad..cabae08e 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -59,25 +59,61 @@ fn main() -> ExitCode { Some("ui") => ui(&args[1..]), #[cfg(feature = "mcp")] Some("mcp") => mcp(&args[1..]), - 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(()) - } + Some("--version") => version(), _ => Err("usage: pdfcraft-cli … (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) => { - eprintln!("pdfcraft-cli: {e}"); + let _ = writeln!(std::io::stderr().lock(), "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 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) } @@ -103,7 +139,7 @@ fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) } -fn info(args: &[String]) -> Result<(), String> { +fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); @@ -132,11 +168,11 @@ fn info(args: &[String]) -> Result<(), String> { "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); - println!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; Ok(()) } -fn text(args: &[String]) -> Result<(), String> { +fn text(args: &[String]) -> Result<(), CliError> { 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 = match flag(args, "--page") { @@ -152,14 +188,14 @@ fn text(args: &[String]) -> Result<(), String> { continue; } if n > 0 { - println!("\u{c}"); + stdout_line(format_args!("\u{c}"))?; } - println!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()); + stdout_line(format_args!("{}", 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::>().join(", "))) + Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) } } @@ -169,7 +205,7 @@ fn page_list(s: &str) -> Result, String> { } /// Apply page and metadata edits through the engine and save (incrementally unless `--full`). -fn edit(args: &[String]) -> Result<(), String> { +fn edit(args: &[String]) -> Result<(), CliError> { 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")?; @@ -204,14 +240,14 @@ fn edit(args: &[String]) -> Result<(), String> { 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}")) + std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) } 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<(), String> { +fn combine(args: &[String]) -> Result<(), CliError> { let out = flag(args, "--out").ok_or("combine: missing --out")?; let inputs = positional(args); if inputs.len() < 2 { @@ -219,20 +255,20 @@ fn combine(args: &[String]) -> Result<(), String> { } let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, 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}")) + std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) } -fn extract(args: &[String]) -> Result<(), String> { +fn extract(args: &[String]) -> Result<(), CliError> { 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}")) + std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) } -fn split(args: &[String]) -> Result<(), String> { +fn split(args: &[String]) -> Result<(), CliError> { use pdfcraft_engine::SplitBy; let path = *positional(args).first().ok_or("split: missing file")?; let by = match (flag(args, "--every"), flag(args, "--before")) { @@ -247,12 +283,12 @@ fn split(args: &[String]) -> Result<(), String> { 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()))?; - println!("{}", name.display()); + stdout_line(format_args!("{}", name.display()))?; } Ok(()) } -fn render(args: &[String]) -> Result<(), String> { +fn render(args: &[String]) -> Result<(), CliError> { 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")?; @@ -260,7 +296,7 @@ fn render(args: &[String]) -> Result<(), String> { 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); + return Err(e.into()); } // 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())?; @@ -271,7 +307,7 @@ fn render(args: &[String]) -> Result<(), String> { } /// Child-process body for `check`: prints one JSON line. -fn check_one(args: &[String]) -> Result<(), String> { +fn check_one(args: &[String]) -> Result<(), CliError> { 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(); @@ -301,7 +337,7 @@ fn check_one(args: &[String]) -> Result<(), String> { } 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::() }); - println!("{line}"); + stdout_line(format_args!("{line}"))?; Ok(()) } @@ -351,7 +387,7 @@ fn collect(paths: &[&str]) -> Vec { out } -fn check(args: &[String]) -> Result<(), String> { +fn check(args: &[String]) -> Result<(), CliError> { let files = collect(&positional(args)); if files.is_empty() { return Err("check: no PDF files found".into()); @@ -425,37 +461,37 @@ fn automation(args: &[String]) -> Result Result<(), String> { +fn tools() -> Result<(), CliError> { let list: Vec = 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(); - println!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + stdout_line(format_args!("{}", 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, out: Option<&str>) -> Result<(), String> { +fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, out: Option<&str>) -> Result<(), CliError> { for c in content { match c { - pdfcraft_automation::Content::Json(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()), + pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", 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())?; - println!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })); + stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; } - None => println!( + None => stdout_line(format_args!( "{}", 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<(), String> { +fn run(args: &[String]) -> Result<(), CliError> { 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}"))?; @@ -463,7 +499,10 @@ fn run(args: &[String]) -> Result<(), String> { 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| 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, + })?; } return Ok(()); } @@ -479,16 +518,16 @@ fn run(args: &[String]) -> Result<(), String> { } #[cfg(feature = "mcp")] -fn mcp(args: &[String]) -> Result<(), String> { +fn mcp(args: &[String]) -> Result<(), CliError> { 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| e.to_string()) + server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) } // ---- UI control channel client ----------------------------------------------------------------- /// One request to a running app's control channel (`pdfcraft --control FILE`). -fn ui(args: &[String]) -> Result<(), String> { +fn ui(args: &[String]) -> Result<(), CliError> { 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 = @@ -525,6 +564,6 @@ fn ui(args: &[String]) -> Result<(), String> { std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; result["png_base64"] = serde_json::json!(format!("written to {out}")); } - println!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; Ok(()) } diff --git a/apps/pdfcraft-cli/tests/stdout.rs b/apps/pdfcraft-cli/tests/stdout.rs new file mode 100644 index 00000000..e75fe0db --- /dev/null +++ b/apps/pdfcraft-cli/tests/stdout.rs @@ -0,0 +1,84 @@ +//! 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")); +} diff --git a/contributors/contributors.json b/contributors/contributors.json new file mode 100644 index 00000000..0ff7a86c --- /dev/null +++ b/contributors/contributors.json @@ -0,0 +1,114 @@ +{ + "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 + } + ] +} diff --git a/contributors/people.toml b/contributors/people.toml new file mode 100644 index 00000000..89c6db20 --- /dev/null +++ b/contributors/people.toml @@ -0,0 +1,23 @@ +# 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 diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 28257d54..ad3dc7bb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -49,3 +49,7 @@ egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } image = { version = "0.25", default-features = false, features = ["png"] } # Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses. skrifa = "0.44" + +[build-dependencies] +serde_json = { workspace = true } +toml = "1" \ No newline at end of file diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs new file mode 100644 index 00000000..f40007fb --- /dev/null +++ b/crates/ui-egui/build.rs @@ -0,0 +1,105 @@ +//! 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::(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::().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(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { + 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 { + 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 +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs new file mode 100644 index 00000000..d21d0e73 --- /dev/null +++ b/crates/ui-egui/src/credits.rs @@ -0,0 +1,405 @@ +//! 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::(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(""), "—"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ad9bbe62..533dcf8f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,4 +1,4 @@ -//! Modal dialogs: Document Properties, Keyboard Shortcuts, About. +//! Modal dialogs: Document Properties, Keyboard Shortcuts, About (with the Contributors and Models credits). use egui::{Align, Layout}; @@ -66,6 +66,7 @@ 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 @@ -1046,30 +1047,49 @@ 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::(tab_id)).unwrap_or(0); 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(crate::i18n::fmt(tl!("Version {v}"), &[("v", env!("CARGO_PKG_VERSION"))])); - }); + for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { + let i = i as u8; + if widgets::mode_tab(ui, tl!(label), tab == i).clicked() { + tab = i; + } + } }); - ui.add_space(6.0); - ui.label(tl!("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(tl!("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.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(crate::i18n::fmt(tl!("Version {v}"), &[("v", env!("CARGO_PKG_VERSION"))])); + }); + }); + ui.add_space(6.0); + ui.label(tl!("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(tl!("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); + } + } } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 826244b6..701d73e1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ mod comments_panel; mod compare_ui; pub mod control; mod create_ui; +mod credits; mod crop; mod export_ui; mod js_ui; diff --git a/crates/ui-egui/tests/links.rs b/crates/ui-egui/tests/links.rs index 072d2509..9b5e3215 100644 --- a/crates/ui-egui/tests/links.rs +++ b/crates/ui-egui/tests/links.rs @@ -64,3 +64,21 @@ 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); +} diff --git a/docs/contributors.md b/docs/contributors.md new file mode 100644 index 00000000..a1b0bcba --- /dev/null +++ b/docs/contributors.md @@ -0,0 +1,55 @@ +# 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. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e4f7c9ae..1a509e35 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"] -notes = "" +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." [[feature]] id = "misc.cli-completeness"