Commit Graph

30 Commits

Author SHA1 Message Date
Sir Clangington III. 7a0f846a28
M13: Run conversion, OCR and optimization over folder trees (#1605)
Add a shared CLI/MCP batch runner with bounded discovery, retained relative
paths and per-file processing in isolated engine sessions. Publish complete
outputs without overwriting existing names, and return structured errors
with an optional stop-on-error policy.

Cover recursive jobs, overlapping inputs, concurrent output collisions,
root confinement and partial-failure reporting in automation and CLI tests.

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-13 00:12:28 -04:00
MarekSpodBiedry 9215b6f3b4
Name the failing option and page count when a chained edit page is out of range (#1550) (#1575) 2026-10-13 00:10:05 -04:00
bmh129 13fbb2f6fb
M0: label local builds in --version, About and the package release (#1529)
A build compiled with PDFCRAFT_BUILD_LABEL=<label> shows "0.7.1 (<label>)"
in `pdfcraft --version`, `pdfcraft-cli --version`, Help > About and the
first log line (pdfcraft_engine::build_info). The version itself is
unchanged, so the update check, the MCP server info and the User-Agent
still carry the release version.

packaging/linux/package.sh puts the label in the file names
(pdfcraft-0.7.1-<label>-linux-x86_64.rpm) and makes the .rpm/.deb release
`1.<label>` (nfpm `release: ${RELEASE}`, 1 when unlabelled). rpm sorts
0.7.1-1.<label> after 0.7.1-1 and before 0.7.2-1, so a locally built
package upgrades over the installed release and the next release replaces
it. packaging/env.sh accepts only letters, digits and inner dots, which
are valid in both rpm and deb release fields.

Unlabelled builds are unchanged.

Made with Claude Code (Claude Opus 5.5, medium effort). It passes the
workspace quality gates (fmt, clippy, cargo test --workspace, xtask assets)
and was checked by hand on Fedora 44, but it has not been exhaustively
regression tested.

Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:32 -04:00
umesh429 6f49967459
fix(cli): edit refuses move/insert destinations past the end (#1330) (#1400)
Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:51:55 -04:00
umesh429 27ce6f7584
CLI: audit rejects unknown options before reading the file (fixes #1332) (#1372)
`pdfcraft-cli audit` skipped option validation, so a misspelled
`--passwrod` fell through to a file-read error instead of naming the
bad option. Audit now runs `validate_options` first, like `info`, and
the options test covers it.

Co-authored-by: umesh429 <3474974+umesh429@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:25:06 -04:00
Master f81e23bd4e
test(cli): reject unknown MCP options (#1342)
* test(cli): reject unknown MCP options

* Tests: wait longer for background text and rendering in viewer tests (FreeBSD CI flake); rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:46:41 -04:00
Maksim Popov d415749556
Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues (#1117)
* Heavy pages: cull off-tile images, cache decodes, preview backdrops, calmer queues

A large CAD sheet (page 24 of the benchmark file: ~5050 pt wide, 2104
images, 9 MB of content) cost a full page interpret per tile: 4.3 s for a
1024 px tile at 1000%, 10.5 s for the whole page, and a storm of renders on
every pan frame. Tiles now cost 0.6 s, the sheet is covered by a handful of
large tiles, and gestures no longer queue work faster than workers finish it.

- Vendored hayro patch (10): skip images whose stream-size box misses the
  canvas before decoding them, so a tile no longer decodes, resamples and
  rasterizes every image on its page. Pixel-identical; the skip regression
  test now covers images too.
- Vendored hayro-interpret patch (23): decoded image pixels shared across
  renders through the interpreter cache, keyed by stream and decode target
  size within a 64 MiB budget.
- Preview renders (hayro patch 11, RenderRequest::preview through to the
  device): backdrops, thumbnails, grid cells and signature-drag backgrounds
  skip images under 16 device px. Tiles, whole pages, print and export always
  draw everything. Previews may differ by those pixels; finals don't.
- Viewer: zoom/pan settle holds apply only to pages whose raster proved slow
  (slow_pages, 500 ms), so fast pages render with no added latency; tile sides
  grow with zoom and page size (giant sheets need ~8 tiles, not ~28), queued
  center-first and keyed by exact coordinates; Edit-text outlines capped.
- vendor/README.md records the new patches with their tests.

Co-Authored-By: Muse Spark <noreply@meta.com>

* Decoded-image cache: key every decode input, survive a poisoned lock

The key was the dictionary's bytes and the target size, so two images with byte-identical
dictionaries (uncompressed, one size) shared pixels, and an image drawn under a transfer
function reused pixels decoded without it. DecodedImageKey now holds the dictionary's place
in the document (its data follows it), its bytes and object, mask/stencil/raster output,
interpolation, the target size and preview or final quality; images under a transfer
function and inline images (named colour spaces, dictionaries in content streams) are not
cached, and an image over the budget is never kept. A poisoned lock decodes uncached
instead of panicking. Soft masks: the per-renderer cache can't carry a preview mask into a
final render (documented). Regression tests for identical dictionaries, preview then final
through one cache, and transfer functions.

---------

Co-authored-by: Muse Spark <noreply@meta.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:42:48 -04:00
ejbarraza 21d5323132
Add faux-redaction audit (paint-order and colour-aware, fail-closed) (#110)
* Rework faux-redaction audit per maintainer review

- Paint-order aware: a rectangle counts as a cover only when painted
  strictly after the text (positions from the joined content stream).
- Colour-aware: WCAG contrast between text and cover must be below 3.0,
  so page backgrounds, table shading and highlights are not flagged.
- Fail closed: audit_redactions returns Err on undecodable content
  streams (decoded_strict) and malformed /Annots; the operator bound is
  an error, not a silent truncation.
- Graphics state carries across a page's content streams (one joined
  walk); form XObjects still isolate their state at Do.
- Audit-only: the sanitize-for-sharing half is dropped (upstream #162).
- Parity entry marked partial, not shipped.

* Audit: register its layer, add a README, fail closed past 32 nested forms, document what it doesn't check

---------

Co-authored-by: Erick Barraza <ejbarraza1994@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 14:32:33 -04:00
piero-83 afb586e249
CLI: reject malformed --root options before dispatch (#1048)
* fix(cli): reject malformed root options before dispatch

* test(cli): reject missing empty and repeated roots

* docs: explain strict root arguments and validation

* docs: log malformed root argument regression

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:12:23 -04:00
cyfung1031 306bbcedf9
M3: harden render and CLI failure surfaces (#669)
* M3: harden render and CLI failure surfaces

Make content truncation observable, reject unknown CLI options, expose deterministic render work counters, and scope render failures to exact requests. Refuse silently reduced whole-page export rasters while preserving tiled rendering, and add heavy-vector and CLI regression coverage.

Co-authored-by: Codex <noreply@openai.com>

* Render: remember a failed render by page and kind again

A key with scale and tag made every zoom level and tile a new key: a
pathological page tied up a worker for the watchdog limit per request
(exhausting worker replacements) and was re-parsed each time. The
repeated-parsing check asks for a new generation and scale again.

* Export: pages too large at the chosen dpi export at the most the renderer allows, and say so

#669 made every Exporter refuse oversized whole-page renders, which
reached File > Export to image, doc_export_* and OCR: at 600 dpi any page
side over 13.65 in (Legal, A3, Tabloid) failed instead of exporting. The
exporter clamps again; Exporter::dpi_used reports the resolution a page
is drawn at, which the Export dialog's message and doc_export_png's
lower_dpi list mention. A source with reject_oversize still refuses
(CLI render keeps its strict config).

* doc_export_images: describe lower_dpi

* Render: ContentTruncated is reported once per page; the hayro-interpret patch is recorded

Nested forms, Type 3 glyphs and patterns are interpreted again and each
reported the exhausted budget; pdfcraft-render now keeps one. The warning
is part of hayro-interpret patch (18) (the page content budget): marked
in the code and described in vendor/README.md.

* CLI render: print render warnings (skipped content) to stderr

* CLI: the option check refuses unknown options only; commands report missing values themselves

text's own --page parsing (and its test on main) says what a page value
must be; the generic "needs a value" pre-empted it.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:32:48 -04:00
jcl-bit 994b42c15c
CLI: pdfcraft-cli.exe carries a Windows version resource (#598)
pdfcraft-cli.exe had no VERSIONINFO, so Explorer's Details tab,
(Get-Item pdfcraft-cli.exe).VersionInfo and inventory tools showed no
version or publisher for it. A build.rs like the app's embeds one with
winresource (already a build dependency of pdfcraft): the same product
name and copyright, its own description and file names, and the package
version. PDFCRAFT_REQUIRE_WINRES=1, which the Windows packaging already
sets, makes a missing resource compiler an error; otherwise it's a
warning, and other targets are unaffected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:09:23 -04:00
jcl-bit 74e30e5803
CLI: pdfcraft-cli ui refuses a control file another user could plant (#597)
* Security: pdfcraft-cli ui refuses a control file another user could plant

`pdfcraft-cli ui --control FILE` sent the token and every command, typed
text included, to whatever port FILE named, without checking who owned it.
The README suggested `/tmp/pc.json`; in a shared /tmp another local user
can create that file first, so the CLI talked to their listener.

The CLI now refuses a symbolic link and anything but a regular file, a file
over 64 KiB, and on Unix a file owned by another user or with any group or
other permission bits (the app writes 600). The owner and mode are read from
the opened file, and its device and inode must match the path's, so a swap
after the check is refused too. std has no geteuid and unsafe is forbidden,
so the effective uid is the owner of a file the CLI creates in the temp
folder and removes. The README now uses a per-user path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: stop the app when --control can't be published

A control file that couldn't be written was only logged, and the app ran
on with a listener nobody could reach. The failure can mean another user
already created a file at a shared path to receive the commands, and a
script started with --control would otherwise drive nothing without being
told. The app now exits with the error, before any document opens, so no
work is lost. The README and the app's usage comment say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CLI: pdfcraft-cli ui refuses a control-file port above 65535

`ui` truncated the control file's port with `as u16`, so 65536 + N reached
port N, and the token and command went to whatever listened there. A port
that doesn't fit is now refused, before connecting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CLI: learn the effective uid from rustix, not a temp-file probe

Review on #597: the probe made `pdfcraft-cli ui` depend on a writable temp
folder to judge a control file somewhere else, created and removed a file
on every call, and could leave one behind. rustix's `geteuid` is safe and
can't fail, and rustix is already in Cargo.lock; it is a Unix-only
dependency of the CLI, so Windows builds don't compile it.

A control file of your own now works with TMPDIR pointing at a folder that
doesn't exist; the probe refused it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:01:05 -04:00
storybored379 db1b3d065d
MCP: common command tools, session resources and client hints (#451)
* XFA: choice lists, signature, password, image and barcode fields; PNG and GIF pictures

The xfa crate lays out the field kinds that were left blank or shown as
text fields:

- Choice lists become combo boxes (editable with textEntry) or list
  boxes (open="always"; multi-select with open="multiSelect"), with the
  shown items and the saved values of <items save="1"> as /Opt pairs,
  /V, /I and /DV as saved values, several on separate lines in the
  data. Field::saved_value maps shown text to saved values for the
  layout and the script tree alike, so scripts read what is saved.
- Password fields are password text fields; signature fields are /Sig
  fields the app can sign.
- Image fields show the picture the data (base64) or the template
  holds; barcode fields show their value as text, with a warning that
  no bars are drawn.
- PNG and GIF pictures (fields and draws) are decoded to Flate image
  XObjects with a soft mask for transparency; JPEGs stay as they were.
  Sizes come from the headers, so layout never decodes; decoding is
  capped at 40 million pixels and failures are warnings, not errors.

Engine: multi-select list values from scripts and data are one per
line. README and parity notes updated (form.xfa-dynamic).

Hardened after an adversarial review pass: picture sizes refused from
the header past 20 million pixels or 65,535 a side, the GIF decoder
given limits (a frame far larger than its screen is refused before
allocation), template pictures read whole up to 32 MB, pictures
decoded once and remembered in the catalog by content hash so
re-layouts reuse them, data pictures that aren't images reported by
field; choice values matched against saved values first, several
values deduplicated and capped, /I ascending and unique, /DV through
the same mapping, <value> children read, the item cap reported; the
reopen path takes another viewer's choice values (several per line);
CMYK JPEGs inverted only with Adobe APP14.

* D5: README for cos and engine, and an honest cos status line

Thirteen crates have no README, although the session protocol tells every session to read the README of the crate it is touching. These are the two biggest: cos is where every hostile file lands first, and engine is the whole API surface a frontend gets.

Each states its layer and the rules that bind it, sketches the API against the real signatures, and ends with what is deliberately not done. The cos README spells out the clone-is-a-snapshot property and why both writers return bytes rather than taking a sink; the engine README spells out the four-step editing model that makes what you see what Save writes.

Also corrects the module doc on cos/src/lib.rs, which still said encryption and object-stream output for full saves were not done. Both shipped: security.rs handles R2-R6 with crypt filters and /EFF, and SaveOptions::object_streams defaults to true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Preserve alternate appearances and indirect push-button values

Keep rollover, down and custom appearance entries when normal appearances are regenerated, without modifying shared dictionaries. Resolve indirect button caption and layout values. Cover full and incremental saved output, and wait for raster readiness in the existing Linux scroll test.

Co-authored-by: Codex <codex@openai.com>

* M13: common MCP tools through the confined automation path

Preserve task tools and compact mode, add complete hints and strict argument-key errors, and catch escaped tool panics.

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* M13: session resources and version-aware MCP result hints

Keep per-document resources and legacy response shapes; add document/command resources and private cache hints for modern clients.

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* M7: select and edit grouped Form XObject figures

Co-Authored-By: Codex <noreply@openai.com>

* M14: let neutral Windows locales fall through to display language

Co-authored-by: Codex <codex@openai.com>

* pdfcraft-cli --help lists `mcp` only when the build has it

Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed
`pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command".

USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty
without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead
of calling it an unknown command.

- tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and
  mcp_without_the_feature_says_it_was_left_out (without the feature)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* engine README: the execution plan is local-only, so don't link a path that doesn't exist

* Merge land/450 into land/414; XFA pictures are found again by a SHA-256 on their XObject

The image cache was a /PCXfaImages map in the catalog, keyed by DefaultHasher
(not stable across Rust releases) and read back from the file. Each image
XObject now records the SHA-256 of its picture's bytes, and a re-layout finds
the XObjects on the pages it replaces by that hash; nothing goes in the catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge land/414 into land/451; keep main's MCP protocol versions, cap command_batch at 1000 steps

The 2026-07-28 revision and its resultType/ttlMs/cacheScope fields are dropped:
main doesn't advertise them and they couldn't be checked against the spec.
command_batch refuses more than 1000 steps. docs/mcp.md says that unknown
argument keys are now refused with -32602 (a behaviour change for clients that
sent extra keys).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Yuvraj Singh <singh.yuvraj31@icloud.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:46:24 -04:00
LloydNicholson fce60eacb4
pdfcraft-cli --help lists `mcp` only when the build has it (#574)
Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed
`pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command".

USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty
without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead
of calling it an unknown command.

- tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and
  mcp_without_the_feature_says_it_was_left_out (without the feature)

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 14:44:11 -04:00
LloydNicholson 5deb42199c
Issue #537: pdfcraft-cli --help prints the full usage (#540)
The per-command options lived only in main.rs's doc header, so a packaged
binary had no way to show them. They move into a USAGE constant (one copy,
no drift) that `--help`, `-h`, `help` and `<command> --help` print to stdout,
exiting 0. No command prints the usage to stderr and fails; an unknown
command is named and points to --help.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:19:31 -04:00
sanyamsingh001 9c63f3e8f2
fix(cli): validate page positions and preserve command results (M13) (#391)
Reject invalid page positions before input I/O and keep diagnostic stderr
failures from overriding rendering, text errors or MCP startup. Preserve
real file and protocol output failures and upstream compact MCP behavior.

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 21:37:09 -04:00
Oldrich333 868bd727d1
M13: `pdfcraft-cli mcp --compact` lists 12 tools and finds the rest by search (#353)
`tools/list` returns all 132 tools (109,578 bytes, ~27k tokens), which an
agent pays on every session. With `--compact` (library:
`McpServer::with_compact(true)`) it returns the 10 core tools
(`mcp::COMPACT_CORE_TOOLS`) plus `tool_search` (query / category / name
with the full schema) and `tool_call` (runs any tool through the same
`Automation::call`): 12 tools, 7,697 bytes. `tools/call` by name keeps
working for every tool; without the flag nothing changes. Same shape as
`lightcraft-cli mcp --compact`; the meta tools are not called
`*_command` because "command" already means a registry command here.

Both meta tools reject unknown outer keys and report failures as
`isError` results; `tool_call` honours `--root` exactly like a direct
call. Tests: automation integration tests (compact list, discovery,
dispatch, errors, root confinement) and a CLI test that launches the
binary with and without `--compact`.

Co-authored-by: Oldrich333 <184516902+Oldrich333@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:55:25 -04:00
jcl-bit f70c4caac9
Security: create save staging files with create_new (follow-up to #164) (#319)
* Security: create the --root staging file with create_new

Follow-up to #164. write_atomic staged every save at the fixed name
.<name>.pdfcraft-tmp and wrote it with fs::write, which follows a
symbolic link and writes through a hard link. So a link planted there,
in a folder someone else can write to, redirected the bytes to any file
the link pointed at.

The staging file now gets a random 64-bit suffix (std's RandomState,
keyed from the operating system's random source) and is opened with
OpenOptions::create_new, which fails if anything already has the name
(a file, a hard link, a symbolic link even when dangling), on Windows
(CREATE_NEW) as on Unix (O_EXCL). A taken name is skipped, never
opened; Windows reports a folder at the name as "access denied", which
counts as taken when something is there. After 16 taken names the save
fails and nothing is overwritten. A failed write or rename removes only
the file this save created. At most 128 bytes of the target's name go
into the staging name, so it fits every system's name limit.

The #164 tests that planted a sentinel at the old fixed name now also
compare folder listings, and say that the "is a folder" refusal is what
they check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Security: create the desktop app's save staging file with create_new

The same fix as the previous commit, for the desktop app.
editing::write_atomically, which Save, Save As, Export, Print to PDF,
signing and crash recovery all use, staged at
.<name>.pdfcraft-<pid>.tmp and opened it with File::create, which
follows a symbolic link and writes through a hard link. A process id is
easy to guess, so a link planted beside a document in a shared folder
could redirect a save into another file.

The staging file now gets a random 64-bit suffix and is opened with
OpenOptions::create_new; a taken name (on Windows, a folder reports
"access denied") is skipped, never opened, and after 16 the save fails
without overwriting anything. At most 128 bytes of the document's name
go into the staging name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 15:19:16 -04:00
Brandon Thomas 4dd738b60f
CLI text tests: each protected-PDF test writes its own files (#267)
text_of_a_protected_document_without_the_password_fails failed on main: both protected
tests wrote to-protect.pdf / protected.pdf / protect.json at the same paths, and tests run
in parallel. Each now uses its own names.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:33:26 -04:00
wstock 2627081935
Issue #249: pdfcraft-cli split creates --out-dir when it does not exist (#256)
`split --out-dir DIR` wrote each part with `std::fs::write` and never
created DIR, so a folder that did not exist yet failed on the first part
with the part's path and a raw OS error ("The system cannot find the path
specified. (os error 3)") — easy to read as the split itself failing.

The folder is now created with `create_dir_all` once the parts have been
computed, before any is written; a folder that can't be created is
reported as `split: --out-dir DIR: <error>`, naming the folder rather
than the first part.

Tests (apps/pdfcraft-cli/tests/split.rs): a nested folder that does not
exist is created and holds every part listed on stdout; an `--out-dir`
that is an existing file fails with a message naming the option and
leaves the file as it was.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 07:26:03 -04:00
wstock 505c4a2fa3
Issue #248: pdfcraft-cli render writes the format its --out name says (.png, .jpg, .tif or .pam) (#255)
`render --out p1.png` wrote a netpbm PAM stream (P7) whatever the name,
so a `.png` could not be opened by any PNG reader. The CLI already links
the engine, whose export module encodes PNG, JPEG and TIFF, so the output
format now follows the extension: `.png`, `.jpg`/`.jpeg` (quality 90),
`.tif`/`.tiff`, and `.pam` for the raw stream as before. Any other name
is refused up front, before the page is rendered, with the accepted list.

`export::encode_image` wraps the three encoders behind `ImageFormat` so
the CLI and `Exporter::image` share one match.

Tests (apps/pdfcraft-cli/tests/render.rs): a `.png` gets the PNG
signature and an IHDR with the rendered size; `.JPG` and `.tif` get
JPEG/TIFF magic; `.pam` still gets the P7 header and the right number of
bytes; `.bmp` fails with the message and writes nothing.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 07:25:43 -04:00
wstock 1e54e709b6
CLI: text fails when the document cannot be opened (#132) (#233)
`PageRenderer::new` keeps a failed open as `None`, so `page_count()` is 0 and
`text` without `--page` iterated nothing, printed nothing and exited 0 on a
password-protected file. When the renderer has no pages, ask `inspect` why
and fail with its message (the same wording `info` gives); a document that
inspects fine but still renders no pages is reported as unparseable. A
genuinely empty document still succeeds.

Fixes #132

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 04:25:52 -04:00
wstock 7bed2d393c
CLI: edit rejects unknown options instead of writing an unchanged copy (#133) (#227)
A misspelled option such as `--rotat` fell through the argument loop's
`_ => {}`, so `pdfcraft-cli edit` wrote a byte-identical copy of the input
and exited 0: an apparently successful edit that did nothing.

Every argument is now parsed before the file is opened. An unknown `--option`
fails with the list of accepted ones, an option without its value is refused,
and a second positional argument is reported. The documented options behave
as before (`--out`, `--password`, `--full` and the edits), and nothing is
written for a rejected command line.

Co-authored-by: William Stock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 03:39:27 -04:00
Brandon Thomas 932967a30d
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 <noreply@anthropic.com>
(cherry picked from commit 90d60b0c98)

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Contributors: regenerate (verified people.toml entries)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 41e214c318)

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

Co-authored-by: Brandon Thomas <echelon@gmail.com>
(cherry picked from commit 1cf0d319e4)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ayan <108534604+excited-raccoon@users.noreply.github.com>
2026-10-08 03:33:35 -04:00
ysrdevs 42ddb2d546
XFA: lay dynamic forms out from their template and keep their data in step (#60) (#121)
* M12: lay dynamic XFA forms out from their template (#60)

A dynamic XFA form is a PDF shell around an XML template: one placeholder
page ("requires Adobe Reader") and /NeedsRendering true. Every viewer
without an XFA engine shows the placeholder, and so did PrintCraft.

New crate printcraft-xfa (L3, already reserved in the layering table). It
reads the XDP packets, parses the template (XFA 3.3: subforms, areas,
fields, draws, exclusion groups, page sets, fonts, paragraphs, borders,
captions, rich text, inline JPEGs, occurrences, breaks, overflow leaders,
column widths), lays it out (positioned, tb, lr-tb, table/row; containers
without a width size to their content; flow across content areas and page
masters; header rows repeat after a break; hidden takes no space; page
number and count fields resolve) and writes real pages and AcroForm
widgets into the document: text, date (AFDate format and keystroke
actions from the picture clause), check boxes, radio groups, push buttons
with reset/print/save/URI actions. Standard-14 fonts stand in for Arial,
Courier New and friends; nothing is embedded.

The engine lays a dynamic form out when it opens it. The original bytes
stay: the layout is one appended revision, the widgets get appearances
from the forms crate, and the XFA packets remain so Adobe's viewers keep
rendering the form their way. A marker on the AcroForm (/PCXfaLayout)
stops a saved form from being laid out twice; each field keeps its SOM
path (/PCSom) for the data round trip to come. doc_info, the CLI's info
and the notice bar report the layout.

Checked on IRCC's IMM 5669 (Schedule A, Designer 6.2, 464 KB template,
AES-encrypted, usage rights): four pages and 308 fields in 27 ms, the
same page structure as the form, filled through form_fill, saved in
place with the original bytes intact and reopened with the values.

Not done, and said so in the parity notes: the datasets packet is neither
read nor written, so Adobe's viewers don't show values filled in here;
no FormCalc or XFA JavaScript (rows don't grow, nothing calculates);
choice lists are text fields; signature, image and barcode fields are
blank; metrics are approximate and fidelity is eyeballed, not measured.

Tests: crates/xfa (parsing, sizing, pagination with repeated headers,
PDF writing, hostile input), the engine (open, fill, incremental save,
reopen without a second layout), the automation tools (open, info,
render, fill, save, reopen) and the UI notice (kittest).

* M12: XFA forms read and write their datasets packet; static forms too (#60)

With the layout alone, a form filled in PrintCraft opened blank in Adobe
Reader, and one filled in Acrobat opened blank here: Adobe's viewers keep
the values in the XFA datasets packet, not in the AcroForm fields.

The xfa crate's new data module reads the datasets packet (normal binding:
a field's node is named after it under its named ancestor subforms,
repeated instances as repeated siblings) and writes it back from the
fields. Laying a form out now takes each field's value from its data node
(dates in ISO form shown in the field's pattern, check boxes and radio
groups by their on values) and gives a repeating subform or table row as
many instances as the data has, so rows another viewer added appear.
Every laid-out field carries its SOM path and its on/off items, so the
packet can be rebuilt from the AcroForm at any time.

The engine writes the datasets after every form edit (one revision with
the field change), and on open applies the datasets to the fields of a
static XFA form, or of a dynamic one laid out earlier, when another
viewer changed them since. The static-form notice now says the two are
kept in step instead of warning that Acrobat may disagree.

Checked on IRCC's IMM 5669: fill, save in place (original bytes intact),
and the file holds <IMM_5669><page1><familyName>SINGH</familyName>…, the
structure Acrobat writes for this form; reopening shows the values.

Not done: explicit bind refs, global binding and data descriptions are
not followed; no import or export of standalone XML or XDP data files.

* M12: XFA layout memoizes container sizes, bounding measurement work (#60)

A container without a width measured its content for its width and again for its
height at every level, so nested width-less subforms took 2^depth measurements, all
synchronously in Session::open. Container sizes are now cached per (node, width,
depth), the root and page-area items keep stable addresses instead of being cloned
per measurement, and a measurement budget turns anything left into TooLarge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12: rustfmt the merged XFA notice (#60)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12: XFA datasets are merged into, not replaced; capped; UTF-16 kept; rewrites recorded (#60)

- write_datasets merged nothing: it replaced <xfa:data> with the AcroForm-bound values
  alone, dropping unbound, namespaced and deep data. It now edits the existing packet
  text in place: a bound node's text is replaced only when it differs, missing nodes are
  added below the deepest existing one, and everything else (unbound data, other
  namespaces, attributes, comments, layout) stays byte for byte. A packet of an XFA array
  that relies on the envelope's prefixes is read inside a wrapper declaring them.
- What can't be written (a node holding structured content, too-deep paths or absurd
  SOM indices, conflicting fields, an unreadable or non-UTF packet) comes back as
  DatasetsWrite.warnings; the engine keeps them in Document.xfa_warnings and
  info.warnings (surviving re-reads), and doc_info reports xfa_warnings.
- Building data counted same-name siblings for every one it added (quadratic in an
  untrusted index) with no total cap. Children are now indexed by name, indices above
  10,000 and paths deeper than 64 are refused, and one write creates at most 20,000 nodes.
- Packets are decoded exactly (UTF-8, UTF-16 LE/BE with or without BOM) and written back
  in the encoding they came in; anything else is reported and left untouched instead of
  being rewritten from a lossy UTF-8 reading.
- A static XFA form whose fields are rewritten from the datasets on open now says so in
  info.warnings, naming the fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:24:54 -04:00
RicSchonfelder 41734376be
i18n: add Brazilian Portuguese (pt-br) UI translation (#165)
* M14: translation catalogs, same system as PhotoCraft

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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

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

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

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

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

Co-Authored-By: 才倾梦华V <185041488+CQMHV@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M14: Brazilian Portuguese as a TSV catalog

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Simon Leblanc <contact@leblanc-simon.eu>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com>
2026-10-07 22:15:26 -04:00
Ayan 1cf0d319e4
M13: exit cleanly when stdout pipe closes (#203)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 22:14:30 -04:00
munzir 85e8e34547
CLI: text exits non-zero when a selected page cannot be read (#131) (#139)
* CLI: text exits non-zero when a selected page cannot be read (#131)

printcraft-cli text --page N reported the failure on stderr and still exited 0, so a script checking the status could not tell a failed extraction from a successful one. The failing pages are collected and the command now fails with them named, pinned by a test for the nonexistent-page case and one for a normal extraction.

* Move the text exit-code test to the renamed pdfcraft-cli app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mohammed Munzir <Mohammed.Munzir@iga.gov.bh>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:14:25 -04:00
jcl-bit 70da0179a6
Security: --root confinement leaks and escapes (#136, #137) (#164)
* Automation: --root refuses every outside path the same way (#136)

With a root set, `resolve` canonicalized a path before checking it against
the root, so an existing outside path got "is outside the allowed
directory" while a missing one got the OS error ("No such file or
directory", "Not a directory", "invalid path"): a confined MCP client or
script could probe which files and folders exist anywhere on the disk.

Containment is now decided before anything about an outside path is
reported:
- `..` is resolved by name first (as Windows does), so a path can't pass
  through an outside folder on its way back in;
- on Windows another network share or device namespace (`\\host\share`,
  `\\?\UNC\...`, `\\.\...`) is refused by name, without contacting it;
- the deepest existing ancestor, links followed, must be inside the root
  before anything is said about the rest;
- a broken link is refused the same way, so whether its target exists
  stays hidden too.

Every outside path now gets `<path> is outside the allowed directory
<root>`, existing or not, file or folder, read or write. Filesystem errors
such as "not found" are still reported for paths inside the root.

Behaviour change: on Unix, `..` now means the parent by name, as on
Windows (`link/../x` is `x` beside `link`), and a write such as
`new/../x.pdf` that stays in the root now succeeds instead of failing with
"'..' is not allowed here".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CLI: script images stay inside --root (#137)

`pdfcraft-cli run --script steps.json --root DIR` wrote each step's
`"out"` PNG with `std::fs::write`, never through the root check the tools
use: an absolute `"out"` created or replaced any file the process could
write, and a relative one landed in the working directory instead of the
root.

The image is now saved through the new `Automation::write_output`, which
uses the same resolver and atomic write as a tool's own output: outside
paths get the usual "is outside the allowed directory" refusal (the step
fails, nothing is written), relative paths resolve inside the root, and
without --root the path is written as given, in place as before (so
`/dev/stdout` still works). `run <tool> --out` goes the same way.

Found while testing this, the same "." problem in two tools:
- a write path naming a folder, such as "." (the root itself), staged its
  temporary file beside that folder, i.e. outside the root for ".",
  overwriting and then deleting any file there called
  `.root.pdfcraft-tmp`. `write_atomic` now refuses a folder before
  writing anything;
- `image_save` adds an extension to a path without one, so "." became
  `root.png` beside the root. It now refuses a folder first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Automation: files named after a document stay in the output folder

Found while reviewing the --root fixes for #136 and #137. Folder outputs
name their files after the document and joined that name straight onto the
resolved folder, without checking the result against the root:

- `doc_export_images` and `doc_export_all_images` used the whole document
  name, so `doc_create` with `name: "../../escape"` followed by
  `doc_export_images` wrote `escape_page_1.png` outside the root (an
  absolute name went anywhere);
- `page_extract` (separate files), `doc_split`, `ocr_recognize_files` and
  `action_run` took the last component, but on Windows a name such as
  `x/C:evil.pdf` gives `C:evil`, and `folder.join("C:evil-page1.pdf")`
  replaces the folder with a path relative to the current directory on C:.

Every such name now goes through `child()`, which turns separators,
colons and control characters into `_` (and prefixes a name of only dots),
so the file is always a direct child of the folder. `with_root` also
refuses a root that is not a folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:12:32 -04:00
Brandon Thomas d3db99818f
Rename PrintCraft to PdfCraft (#174)
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now
PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the
PrintCraft settings and crash-recovery folders to the new name, and both apps read settings
saved under the old storage key, so upgrades keep recent files, preferences and unsaved work.
The signing test fixtures keep their PrintCraft subject names (they are binary).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:57 -04:00