* XFA: run FormCalc scripts
A FormCalc interpreter (XFA 3.3 part 8) in the js crate, over the same
form table, effects and budgets as the XFA JavaScript model, so the
engine runs both languages alike: expressions and FormCalc coercions,
var, if/while/for/foreach/func, accessors ($, $host, $form, $record,
$layout, $event, xfa., .., [n], [*], _name instance managers) and the
arithmetic, logical, string, date/time, financial and unit built-ins.
Get/Post/Put refuse.
The JavaScript model's XHost, HNode, SOM resolution and instance
operations are shared (pub(crate)) with Context-free add_instance and
remove_instance that the boa wrappers call.
Bounded like the JavaScript path: steps, loop iterations, recursion,
parse depth, string and list sizes, clamped dates, the instance cap,
and the per-activity time budget with abandonment.
Tests: language, built-ins, control flow, form objects and hostile
scripts in the js crate; a FormCalc fixture form in the engine that
initializes, calculates, validates, adds and removes rows, shows a
subform and round-trips through save; hostile scripts at the engine
level. parity form.xfa-formcalc planned -> partial.
Hardened after two adversarial review passes: statement nesting and
chain length count against the limits (verified on a 1 MiB stack), the
script honours the engine's deadline itself, strings are capped after
every built-in and messages/values clipped and noted (shared host),
lists hold each object once and the shared SOM resolver deduplicates,
values go into fields only (shared host, JavaScript too), saturating
Stuff and time arithmetic, FormCalc-specific size check instead of the
JavaScript refuse scan. Semantics: bare row[*] and row[n] from inside a
row see every row, relative [-n]/[+n] indexes, Count of non-null,
Parse with a date picture gives the ISO date, assignment is an
expression.
* form.xfa-formcalc: bound FormCalc values, results, errors and copying (#301 review)
- XHost::set_value cuts a value to 64 Ki chars before storing it, so later
reads match what is saved and the snapshot stays bounded.
- A script's completion value (calculate result) is cut to 64 Ki chars with a
note, in both the FormCalc and the JavaScript paths.
- throw clips its value like any message.
- Copying, dereferencing and comparing long strings is charged to the step
budget (len/256), so wasm (no clock) can't spend unbounded time on it;
OneOf charges per comparison.
- Exists propagates step/deadline/depth/size limits, answering 0 only for
lookup failures.
- IPmt/PPmt charge a step per period.
- The parser counts each top-level expression's chain from zero, so an
elseif condition no longer inherits the previous arm's count.
- Comment on the interpreter's depth limit fixed; parity notes say memory
isn't capped and Uuid/Date/Time are deterministic on wasm.
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>
Validate the optional flag for clean documents before closing them. Add
regressions for invalid types, document preservation, and valid defaults.
AI-generated by Codex (GPT-6), with GPT-6.1 Sol and GPT-6 Astra agents.
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
List Current User Personal CNG identities alongside file digital IDs and sign
without exporting their private keys through safe rustls-cng wrappers.
Expose Windows store references through automation and the desktop dialogs,
with RSA and ECDSA validation, certificate cleanup, and UI regression coverage.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
When the browser app's `?file=` URL couldn't be fetched (an HTTP 404, a
network or CORS failure), the error went only to the developer console and
the user saw the ordinary welcome screen with no sign the PDF was tried.
The fetch now also queues `(name, error)` in a new `failed_inbox` next to
the existing `inbox`, and the app reports it on the next frame with the
notice it already uses for a delivered file that won't open: "Couldn't open
missing.pdf: HTTP 404". The console line stays for developers.
Test: a_failed_startup_url_is_reported_in_the_app (pickers.rs).
Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
pkcs12::open demanded clean DER at the first byte, so a file with one
extra newline, PEM armour or a bare base64 body (the shapes OpenSSL
wraps, editors and government portals emit) was refused before any
crypto ran, with "malformed data: PKCS #12: not a PKCS #12 file" and no
reason.
open now runs the bytes through normalize(): ASCII whitespace is
trimmed, "-----BEGIN" takes the base64 body between the armour markers
(byte offsets, so a marker glued to the body line works too), and a
bare base64 body is decoded — a real DER PFX always contains bytes
outside the base64 alphabet, so the fast path can't swallow one. The
exact parse reason is carried into the error instead of being dropped.
Truncated or empty files still fail, now saying why.
Test: crypto.rs::opens_wrapped_pkcs12_files_and_still_rejects_broken_ones
(a wrapped copy of rsa-aes.p12 yields the same certificate through
trailing whitespace, PEM armour and bare base64; truncated and empty
files error with the reason). It failed on all three wrapped forms
before the fix.
Co-authored-by: Mawfyy <mawfy12@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M10: select pages in the Pages panel and print the selection
The Pages panel could only jump to a page. It now picks several:
Ctrl/Cmd-click toggles a page (the first one keeps the current page
selected too), Shift-click takes a range from the last clicked page, a
plain click goes to the page and starts over, Escape clears. Picking does
not turn the page. It is the selection the Organize grid and the page
commands already use; the click rules moved to DocView::click_page and
both places call it.
Print opens on a new "Selected pages" choice when pages are picked and
prints them in page order. They are passed as positions
(print::select_listed), not as a typed range, which would read numbers as
page labels first. ui.state reports active.selected_pages.
Windows still has no printer output, so there the result is a PDF of the
selected pages. No context menu or drag in the panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Restore ROADMAP.md, emptied by accident in this PR
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>
* Layers: run set-layer-visibility actions, keeping radio-button groups
The engine can apply a set-layer-visibility action (SetOCGState,
ISO 32000-2 §12.6.4.13) to a document's layers: each ON, OFF or Toggle
in order, naming the layer by its optional content group, so a toggle
sees the state the changes before it left. With /PreserveRB (the
default), a layer turned on turns off the other layers of its
radio-button groups, now read from /OCProperties /D /RBGroups; turning
one off leaves them alone. The renderer is rebuilt once per action, not
once per layer.
Like the Layers panel, this changes the view, not the document.
Tests: engine set_layer_actions_toggle_in_order_and_keep_radio_groups
and set_layer_actions_change_what_renders (pixels before and after),
render radio_button_layer_groups_are_read.
* Links: run set-layer-visibility actions
Clicking a link whose action is SetOCGState showed "SetOCGState actions
run in the JavaScript engine (M6)" and did nothing. Links now read the
action (/State, also when indirect, and /PreserveRB) and clicking runs
it; the Layers panel follows. Hovering shows "Set layer visibility"
(translated in the ja, es, zh-hans and zh-hant catalogs), and doc_info
describes the action in its link list.
Tests: render links_read_set_layer_actions, automation
doc_info_describes_set_layer_links and ui-egui
links_set_layer_visibility.
* Forms: push buttons run set-layer-visibility actions
A push button whose mouse-up action is SetOCGState counted as having no
action, so clicking it did nothing. Buttons now read the action as
links do, and clicking runs it.
Tests: forms push_buttons_read_set_layer_actions and ui-egui
push_buttons_set_layer_visibility.
parity: edit.action-set-ocg-state is partial. The notes say what is
missing: creating the action, bookmarks and other triggers, /Next
chains, JavaScript ocg.state, and the Layers panel keeping radio-button
groups itself.
* French: Set layer visibility
The French catalog is coverage-checked since #291.
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>
Add Français (fr) as a complete UI catalog and select it for every
French locale. Preferences lists it, and Auto follows fr, fr_FR,
fr_CA and fr_BE. About credits labels now go through the catalog.
Coverage tests check commands, All tools labels and every tl! literal.
The bundled Latin faces cover the catalog.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: iMhack <iMhack87@users.noreply.github.com>
A tab named in Arabic showed replacement boxes: no interface font had the
glyphs, and egui joins the letters of a right-to-left word but places the
runs in logical order.
- Fonts: the interface takes `Arab` faces from the craft-fonts build input
(also on wasm), and on desktop adds one font already installed on the
machine as the last fallback of every family. It is read at runtime, never
embedded; PDFCRAFT_SYSTEM_FONTS=0 turns it off and `cargo xtask screenshots`
sets that. AGENTS.md §1.4 records the rule.
- Order: bidi::visual puts single-line text in display order with a
left-to-right base, used for the painted tab label and hover text, the
recent-files name and path, and the Properties File and Location rows.
Accessibility names keep the logical text.
misc.rtl-ui stays planned: multi-line text, text input and a mirrored layout
are not done.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The quick toolbar's tooltips read "Select (V)" and "Hand (H)", but
nothing listened for either key. V now selects, H picks the Hand, and
holding Space pans with the Hand until it is released, as in Acrobat.
They are plain letters, so not while a text field, a form field, a
dialog or the palette has the keyboard, and not with a modifier held.
The shortcuts dialog lists them (reusing the translated tooltips) and
the view.hand-tool parity entry cites the new test.
Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Issue #166: Save, Print and Export include the text still being typed in a form field
A field's editor kept its text in a draft until Enter, Tab or clicking away,
and ⌘S (which runs while a field has focus) saved the document first. Typing
also didn't count as unsaved, so closing or quitting didn't ask.
- commit_form_typing(): apply the tab's queued edit, then commit the open
field's draft; if the field refuses the value, keep the editor and text
and stop. Called where the document is read: Save, Save As (and its later
callback), Print, Export, Reduce/Optimize, Sign, Extract, Split, Copy
pages, Flatten, button scripts.
- Uncommitted typing counts as unsaved work for Close, Close All and Quit;
saving from the prompt brings that tab forward first.
- Shortcuts pressed while a form field's editor is open run one frame later,
for that document only, so the field takes the frame's typing and Escape.
- A value refused on Enter/Tab/blur reopens the field with the text.
- Notices show in full screen too.
Co-authored-by: Hal Goff <hal@hg.studio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add my contributor name (AutoHenry)
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>
Also drop "signed installers" from the thin-or-missing list: release installers are signed.
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Release: Flatpak bundles (x86_64, aarch64), AppImage zsync updates, README Downloads
- AppImages embed gh-releases-zsync update information and ship a .zsync beside them;
the Linux job installs zsync and checks both.
- New flatpak job (x86_64 on ubuntu-24.04, aarch64 on ubuntu-24.04-arm) repackages the
Linux tarball with packaging/linux/flatpak-bundle.sh and the new bundle manifest, then
smoke-tests it in the sandbox. finish-args shared with the Flathub manifest (packaging-lint
checks they match): Wayland/X11, dri, CUPS for printing, Documents and Downloads.
- Only the signing jobs and the draft-release job keep `environment: release`, so a branch
dispatch is a dry run of everything else and can never draft a release.
- README: a Downloads section listing every build; docs/releasing.md updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Flatpak: no CUPS socket (the runtime has no lp); document printing as unavailable there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ISO 32000-2 §12.5.5 maps an appearance onto its annotation with a matrix
A that takes the lower-left and upper-right corners of the transformed
appearance box to those of /Rect. The vendored interpreter scaled by
/Rect size over box size but translated by /Rect.x0 - box.x0, which is
only right when the box starts at the origin or the scale is 1. Any
other appearance was drawn shifted by (scale - 1) × box origin, partly
outside its /Rect: /BBox [10 10 30 30] in /Rect [10 10 60 40] painted
x 25..74, y 55..84 instead of x 10..59, y 60..89 (device pixels).
MuPDF, Poppler, PDFium and pdf.js all fill /Rect exactly.
The translation is now /Rect.x0 - scale × box.x0 (and likewise for y).
Boxes at the origin, and boxes as large as /Rect (PdfCraft's own
appearances use /BBox = /Rect), map exactly as before. A box that
collapses to a line still yields a non-finite matrix and is skipped.
Test: raster::tests::annotation_appearance_box_maps_onto_rect. An offset
/BBox, an offset made by /Matrix and a negative-origin /BBox now fill
exactly /Rect (on main the first is drawn at (25, 55)-(74, 84)); a box at
the origin and an offset box as large as /Rect are unchanged. Vendored
patch (15) in vendor/README.md (after (14) in #276).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The vendored hayro-interpret reads /FirstChar, /LastChar and /Widths of
every simple font (TrueType, Type 1, Type 3, standard 14) in read_widths.
Three defects there, each checked against MuPDF, Poppler, PDFium and
pdf.js on synthetic files:
- One entry was pushed per code below /FirstChar before any width was
read. 50,000,000 already took 276 MB; 4294967295 asks for 34 GB, and
a larger value aborts on allocation (AGENTS.md §4: cap allocations
sized by the file). Simple-font codes are single bytes, so only codes
0 to 255 now get an entry.
- An entry that isn't a number (null, a name) ended the array, so every
later code lost its width and glyphs piled up. It now leaves just that
code missing (/MissingWidth); the four renderers do the same.
- A /LastChar below /FirstChar, or a /FirstChar so large the count
overflowed, made read_widths fail, so the font didn't load and a
standard font stood in for the embedded program. The font now loads
with no widths from the array; the four renderers keep the font too.
Valid /Widths read exactly as before, including explicit zero widths,
which all four renderers and Apple Quartz (sips) honour literally.
Tests (a Type 3 font, so no font program is needed):
raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font
fails on main for [null 600], [/W 600], /LastChar < /FirstChar and
/FirstChar 4294967295; raster::tests::huge_simple_font_first_char_terminates
renders /FirstChar 4294967295 /LastChar 4294967295 quickly (not run
against the old code: it would exhaust memory). Vendored patch (14).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A Highlight annotation (ISO 32000-2 §12.5.6.10) whose /AP is missing, as
many older and generated files have, was not drawn at all: the vendored
interpreter only draws annotations through their normal appearance.
MuPDF, Poppler and PDFium all draw such highlights.
When there is no usable /AP /N, the interpreter now fills the
/QuadPoints quadrilaterals with /C at /CA opacity, blended with
Multiply, the way pdfcraft-annot draws PdfCraft's own highlights. It
uses the interpreter's own fill path, so clipping, layers and the device
see an ordinary fill. pdfcraft-annot itself can't be reused here: render
and annot are both L3, and it builds pdfcraft-cos objects while hayro
reads hayro-syntax ones. Nothing changes when an /AP exists. Malformed
/QuadPoints (not a non-empty multiple of eight numbers) and an absent,
empty or malformed /C draw nothing, as pdfcraft-annot does. Hidden,
NoView and "Hide all comments" are checked before this point and still
apply. Only Highlight changes; the source PDF is not modified.
Test: raster::tests::highlights_without_an_appearance_are_drawn. On
main the quad stays white; now it is yellow over white and black over
black (Multiply), two quads leave their gap white, /CA 0.5 and gray /C
match MuPDF, Poppler and PDFium. An /AP still wins, and ten malformed or
hidden variants draw nothing. Vendored patch (13) in vendor/README.md;
the parity entry view.render-annotation-appearances cites the test and
notes that other comment types without /AP are still not drawn.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ISO 32000-2 §12.5.5 maps an appearance onto its annotation by transforming
/BBox with /Matrix and scaling the resulting box onto /Rect. When /BBox has
zero width or height, or /Matrix has a zero row or column, that box is a
line or a point, and the vendored interpreter divided the /Rect size by
zero. The infinite or NaN matrix then reached the device with every path,
clip and glyph the appearance drew (4 of 4 drawing calls in the test).
hayro's rasteriser happens to drop such geometry, so pages looked right,
but nothing guaranteed it; AGENTS.md §4 asks for division by zero and NaN
to be guarded where they arise. Clipped to a collapsed box the appearance
shows nothing (MuPDF and Poppler draw nothing for these fixtures either),
so the appearance is now skipped, as is one whose mapping overflows. The
mapping of valid appearances is unchanged.
Test: raster::tests::degenerate_annotation_appearances_are_skipped. A
recording device sees non-finite geometry on main for a zero-width,
zero-height and point /BBox and for a /Matrix with a zero row or column;
none after. Each page renders blank; a valid appearance still fills its
/Rect and nothing outside it. Vendored patch (12) in vendor/README.md.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
#167: browser file requests are bound to the document active when the
picker opened (id + edit generation), the same check desktop picks use,
now shared from files.rs. A request that arrives after a switch or an edit
is refused with a notice. Replace keeps that target through its dialog, so
OK never replaces pages in a document opened meanwhile (desktop too).
Browser picks wake the UI once queued, and run under the last-resort guard.
#170: on wasm, Save as PDF downloads "<name> (print).pdf" as Save does,
instead of returning false silently; it never takes the file-system path.
The Print dialog stays open when printing or saving fails.
Co-authored-by: Hal Goff <hal@hg.studio>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
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>
The current page is the page with the largest visible height. When more
than one row of pages is wholly on screen (a book with a portrait cover
and landscape spreads, at Fit Width or Fit Page), those heights differ
only by sub-point rounding after the scroll, so the "current" page was
whichever wholly visible page rounding favoured. ⌘Page Down then stepped
from the wrong spread: from the cover it went to 2–3 and then to 6–7,
skipping 4–5 (or, the other way round, stayed put after the cover).
Visible heights within half a point are now equal (the topmost wins),
and the page navigated to stays current while no page shows more of
itself, so the next step starts from it.
Test: viewer.rs::page_down_steps_every_spread_when_several_fit_on_screen
(a 12-page book with a cover page at Fit Width, Fit Page and Fit Height:
⌘Page Down visits 1, 3, 5, 7, 9, 11 and ⌘Page Up 9, 7, 5, 3, 1, 0).
Before the fix it went 1, 5, 7, 11, 11, 11 at Fit Width. Cited on the
view.cover-page parity entry.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Every remaining rfd::FileDialog (26 call sites) ran -[NSSavePanel runModal]
inside winit's event handler, which aborts the app when the window gets an
event while the panel is open. They all go through pickers::ask now: an
rfd::AsyncFileDialog, with the follow-up run as a closure on a later frame.
- One picker at a time, held until its closure starts, so a closure that
asks again (merge data, run action) gets its picker; refusals are told.
- Closures run inside pdfcraft_engine::guard, as commands do.
- Targets drop a pick if the document is no longer active or was edited.
- Save-on-close closes only once written; CloseRequest::Tab holds a DocId.
- Extract+delete deletes only after writing, and only if unedited.
- Signing happens once the path is chosen, on an unchanged document.
- Browse… results land only in the same showing of the dialog.
- Sign, print-to-PDF and attachment saves write atomically.
Co-authored-by: Hal Goff <hal@hg.studio>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The internal path dependencies carry an explicit version requirement, so they move to 0.3.0 too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the continuous (default) and two-page views, → / ← and Page Down /
Page Up only did something with ⌘ held, and ↓ / ↑ never did: egui's
ScrollArea has no keyboard scrolling of its own, so without a modifier
the keyboard could not move through the document at all.
As in Acrobat: → / ← now go to the next / previous page (a spread in
two-page view) in every layout; ↓ / ↑ scroll a line (40 pt, a desktop
mouse-wheel line) and Page Down / Page Up scroll a screen (less one
line) where the pages scroll. ⌘ + those keys and single-page view keep
their page steps. The scroll is queued on the DocView and applied by the
page view's ScrollArea in the same frame, next to the middle-button
auto-scroll.
The shortcuts list shows ← / → beside ⌘← / ⌘→; the view.page-navigation
parity entry cites the new test.
Test: viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document
(continuous at Fit Width: → / ← turn pages; ↓ and Page Down take
several presses to reach page 2, ↓ more than Page Down, and ↑ / Page Up
come back; → in two-page view and Page Down in single-page view). It
failed at the first → before the fix.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The page view's ScrollArea had no id of its own, so egui derived it from
the parent Ui, which is the same for every document. All tabs therefore
shared one scroll offset: switching to another document showed it at
wherever the previous one had been scrolled to (clamped to its length),
and switching back showed the first document at the other one's offset,
usually the top.
Salt the ScrollArea with the document's id so egui keeps one offset per
tab.
Test: viewer.rs::each_document_keeps_its_own_scroll_position scrolls
a.pdf to page 4, switches to b.pdf and scrolls it to page 2, then
switches back and forth: before the fix b.pdf opened on page 2 instead
of its own top. Cited as evidence on view.document-tabs.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`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>
`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>
`comment_add` converted a note's or attachment's `at` (the icon's top-left
as displayed) as a single point through `PageInfo::view_to_user`, and the
engine's `rect_for` extends the icon square from `at` toward +x/-y in user
space. On an unrotated page the displayed and user-space top-left are the
same corner; under `/Rotate` they are different corners of the square, so
the icon landed one icon width (20 pt) away from the requested point.
`icon_anchor` converts the whole displayed square to user space and takes
its user-space top-left, so the `/Rect` covers the requested square under
any rotation. `PageInfo::view_rect_to_user` is the render-side counterpart
of `view_rect_to_quad`, and `NOTE_SIZE` is re-exported from the engine.
Test: `note_icons_anchor_at_the_requested_corner_on_rotated_pages` adds a
note and an attachment on pages rotated 0/90/180/270 and asserts the
`comment_list` rectangles; before the fix the 90° page reports
[52, 72, 72, 92] for a note asked for at [72, 72].
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>
eprintln! panics when standard error is a broken pipe (a GUI launch on
Windows), and a panic inside the panic hook aborts. The "no log file"
message goes through log::warn! (standard error only by then); the panic
hook's fallback writes to standard error with write_fmt and ignores the
error. Follow-up to #239, same fix as storytold/vectorcraft#465.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The schema documented printing/changes/copy/accessibility and their
defaults unconditionally, but the engine (like Acrobat) only enforces
/P behind an owner password: open_password alone encrypts the document
and leaves everything allowed, even when a caller passed copy: false
or changes: "none" explicitly, and the call reported success.
State the condition in the tool and property descriptions, and turn an
explicit restriction without permissions_password into InvalidArgs that
names the argument and what is needed, instead of writing an
unrestricted file. open_password alone is unchanged.
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>
doc_info (and the MCP document-info resource built on it) serialised
annotation and link rectangles straight from the PDF /Rect, in user
space, while every other tool reports and accepts rectangles with the
origin at the top-left of the displayed page. Feeding a doc_info rect
to link_edit therefore moved the link, and on rotated pages the two
conventions disagreed in more than a y flip.
Convert both through the page's user_to_view, the same conversion
comment_list and link_list use, so the three agree. Pages that an
annotation cannot be matched to keep the raw rect.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
- The wheel turns pages in single-page view: one per notch, one per swipe.
- The rail's Page display menu offers Acrobat's view choices.
- Preferences: default page display and default zoom.
- Fit page and Fit height no longer jump across mixed page sizes.
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: a failed autosave, a page that would not
render, a render worker that could not start. A launch from a desktop
menu has no terminal either, so there was nothing to attach to a bug
report.
apps/pdfcraft/src/logging.rs (no new dependency besides log):
- standard error plus <settings dir>/logs/pdfcraft.log, next to app.ron
(Linux $XDG_DATA_HOME/pdfcraft); each launch rotates the previous log
to pdfcraft.1.log and .2.log, so the log of a run that crashed
survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
(bounded) and written once the file is attached
- default levels: info for pdfcraft*, warn for everything else;
RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
error as the only sink
The logger is installed first in main, so the panic hook's report is
recorded too; the start-up messages in main go through it. The file is
opened after argument parsing (--version leaves no file) and after the
PrintCraft folder migration, which a fresh settings folder would block.
Documented in docs/development.md (Logs, environment variables).
`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>
* M12: run XFA forms' JavaScript: calculations, validation, rows, visibility (#60)
Laid-out XFA forms had their fields but none of their behaviour: totals
didn't compute, "Add row" buttons did nothing, and subforms a check box
should reveal stayed hidden.
The js crate gains the XFA scripting object model as native boa objects
over a snapshot of the form tree: xfa.form navigation by name, SOM
resolution (absolute, relative with the containing-subform search,
descendants, indexes and wildcards), rawValue/presence/access, instance
managers (_name, addInstance, removeInstance, setInstances, count),
xfa.host (messageBox, resetData, print, setFocus, gotoURL), xfa.layout
page numbers, xfa.event, app, console and util, plus unqualified sibling
names as XFA resolves them. Scripts run with `this` bound to the node and
yield their completion value; everything they change or ask for comes
back as effects.
The xfa crate builds the live form tree (instances from the data, values,
presence and access with overrides) and the event table from the
template, keeps presence and access overrides on the AcroForm
(/PCXfaOverrides) so undo and reopening preserve them, adds and removes
row instances in the datasets packet through the existing merge
pipeline, and lays the form out again on demand.
The engine runs initialize and calculate scripts on open (an appended
revision, nothing dirty), exit, validate and calculate after a field
changes (a failing validate shows its message and keeps the value, as
Acrobat does), and click scripts for buttons through run_javascript, so
the app's buttons and the js_run tool work unchanged. Values a script
sets go to the fields and the data at once; rows and visibility changes
re-lay the form out. All of it is one undo step. JavaScript off turns it
off. FormCalc scripts are recognised and reported, not run.
Bounds: script length and nesting as before, loop and recursion limits,
at most 1000 instances added per run, 2000 scripts and 50 re-layouts per
event; a calculate script that adds rows, or a click script looping on
addInstance/setInstances, ends quickly with a bounded form (tested).
Checked on IRCC's IMM 5669: opens with its page-number scripts, the Print
button's xfa.host.print runs through js_run, fill and save keep the
original bytes.
* M12: Save and SaveAs named actions run the file commands (#60)
XFA forms' Save buttons are app.execMenuItem("SaveAs"), which the layout
turns into a SaveAs named action; the shell answered it with "the SaveAs
action isn't supported yet". Named Save and SaveAs now run file.save and
file.save_as, like Named Print opens the print dialog.
The buttons test covers Save against a file on disk (SaveAs opens the
native dialog, which a headless test must not).
* M12: bound XFA form scripts against hostile forms (#60)
Review fixes for the XFA scripting PR (#230):
- Effects: values, presence and access set again on the same object merge
(last wins, behind order-dependent effects); 10k effects, 100 message boxes
and 1000 console lines per script and per event, reported when hit. An
event's data writes are kept in memory and written as one rewrite of the
datasets packet; later writes in the same edit replace that stream in place.
- form_tree stops at 200k objects and says so; forms without scripts build no
live form and run nothing.
- Only click scripts may print, save, open links or move the focus; others are
noted in the console. Open-time messages and errors show right after open.
- Lower loop limits for initialize/calculate/validate, a time budget per event
after which remaining scripts are skipped and reported, and a per-script time
limit after which a runaway script is abandoned and the document's scripts
turned off.
- What open-time scripts change is noted in the XFA warnings.
- A document's Named /Save no longer writes the file on click (SaveAs opens
the Save As dialog, as a script's execMenuItem does).
- XFA click scripts are no longer written as /S /JavaScript actions: the
button carries /PCXfaClick and runs by SOM path. Private keys documented.
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>
* Split: keep only the resources each part draws (#204)
In a book whose pages share one resource dictionary, every split part carried every XObject the source listed: a part saved out at the source's size although its pages drew a fraction of the images. extract_pages now walks each part's content (form XObjects included) and drops the /XObject entries no page names; the garbage-collecting rewrite on save then drops the objects themselves. Tests cover a one-page part that must lose the other page's image and a part that needs both.
* Split/Extract pruning never drops an XObject something might still draw
A resources dictionary is left untouched when one of its pages has content
that can't be decoded, when a form nests past the depth limit, or when it
lists a Type 3 font without its own /Resources (its glyphs draw through
the page's). Regression tests fail without this.
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>
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>
* 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>
a_panicking_worker_clears_showing_and_queues_nothing failed on main (Linux CI and
Windows ARM64): panic unwinding on a loaded runner can take longer than the
1 s polling window. Tests now join the worker thread, so they can't time out.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M3: unify toolbar and menu theme preferences
Co-authored-by: Codex <noreply@openai.com>
* M3: use the current egui style API in theme regression
Co-authored-by: Codex <noreply@openai.com>
---------
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(i18n): follow Windows display language for Chinese UI
M14: resolve Auto through the Windows UI culture after locale environment
variables. Reuse Chinese region/script mapping and cover the query and
fallback behavior.
Refs #215
Co-authored-by: Codex <noreply@openai.com>
* Windows Auto language: ask Windows directly instead of starting PowerShell
sys-locale (1Password, MIT OR Apache-2.0) reads GetUserPreferredUILanguages, so
Auto no longer launches a hidden powershell.exe at startup (slow, and the kind of
behaviour endpoint security tools flag). All preferred display languages are
tried in order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M12: add calibrated 2D measurement tools
* M12: harden measurement tools for landing (#208)
Bounded snap targets (endpoints only for accepted segments, page-wide cap),
lenient extraction (deep q nesting and undecodable streams are skipped and
reported), per-page snap cache, measure::list reports unsupported entries
instead of failing, closed polygons with a repeated vertex, printable unit
labels (m²), cached panel listing, WinAnsi captions, indirect /VP kept,
measure.export skipped in the command test like other pickers, missing
translations, honest parity (scale and distance partial) and ROADMAP.
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>
* M14: complete Simplified Chinese translation catalog
* M14: translate new XFA notices and counts
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>