Restore #196, #202, #203 and the macOS release-runner change, reverted by the i18n chain (#229)

* 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 <[email protected]>
(cherry picked from commit 90d60b0c98)

* CI: tell actionlint about the org's macos-release runner label (#202)

Co-authored-by: Claude Opus 5.5 <[email protected]>
(cherry picked from commit ddea3c72e0)

* 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 <[email protected]>

* 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 <[email protected]>

* Contributors: regenerate (verified people.toml entries)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 41e214c318)

* M13: exit cleanly when stdout pipe closes (#203)

Co-authored-by: Brandon Thomas <[email protected]>
(cherry picked from commit 1cf0d319e4)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Ayan <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-08 03:33:35 -04:00
1 parent 24cdc7e31b
commit 932967a30d
17 files changed
+961 -67

No files matched your search

+4
View File
@@ -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
+1 -1
View File
@@ -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 }}
+19
View File
@@ -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.<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).
Generated
+1
View File
@@ -3794,6 +3794,7 @@ dependencies = [
"serde_json",
"skrifa 0.44.0",
"sys-locale",
"toml",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+3 -1
View File
@@ -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%.
+79 -40
View File
@@ -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 <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) => {
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<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)
}
@@ -103,7 +139,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<(), 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::<Vec<_>>(),
"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<usize> = 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::<Vec<_>>().join(", ")))
Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")).into())
}
}
@@ -169,7 +205,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<(), 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::<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}"))
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::<String>() });
println!("{line}");
stdout_line(format_args!("{line}"))?;
Ok(())
}
@@ -351,7 +387,7 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
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<pdfcraft_automation::Automation, String
}
}
fn tools() -> Result<(), String> {
fn tools() -> Result<(), CliError> {
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();
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<pdfcraft_automation::Content>, out: Option<&str>) -> Result<(), String> {
fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_automation::Content>, 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(())
}
+84
View File
@@ -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"));
}
+114
View File
@@ -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
}
]
}
+23
View File
@@ -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
+4
View File
@@ -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"
+105
View File
@@ -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::<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
@@ -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::<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(""), "—");
}
}
+43 -23
View File
@@ -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::<u8>(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);
}
}
}
}
}
+1
View File
@@ -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;
+18
View File
@@ -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);
}
+55
View File
@@ -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.
+2 -2
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"]
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"