mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
* 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 commit90d60b0c98) * CI: tell actionlint about the org's macos-release runner label (#202) Co-authored-by: Claude Opus 5.5 <[email protected]> (cherry picked from commitddea3c72e0) * 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 commit41e214c318) * M13: exit cleanly when stdout pipe closes (#203) Co-authored-by: Brandon Thomas <[email protected]> (cherry picked from commit1cf0d319e4) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Ayan <[email protected]>
This commit is contained in:
17 files changed
+961
-67
No files matched your search
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -3794,6 +3794,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"skrifa 0.44.0",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
|
||||
+3
-1
@@ -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%.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(""), "—");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user