* Security: refuse email links that attach or insert a local file
Follow-up to #104. A document's mailto: link could ask the email app to
attach a file from the user's computer (Evolution, KMail and Claws Mail
honour attach=, Evolution and KMail also attachment=) or read one into
the body (Claws Mail's insert=).
document_url now refuses a mailto: address in which any word starting
with attach or insert (any case) is followed by =, wherever it stands
and after each layer of percent-decoding. It also refuses text that
some email tools decode before reading the address: RFC 2047 encoded
words, which KMail decodes across the whole address, and backslash
escapes, which xdg-email's echo decodes. The link is refused rather
than rewritten: PdfCraft doesn't change what a document asks to open,
and the email apps' parsers disagree with one another.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Security: show a link's host as the browser receives it, in punycode
Follow-up to #104. The link prompt's host now comes from
links::display_host, which reads it with url::Url: the webbrowser crate
hands the OS that crate's serialisation, and the web build's browser
parses by the same WHATWG standard. International hosts are shown in
punycode with a note, a label mixing writing systems (UTS #39 resolved
script sets) gets a red warning, numeric hosts such as 3232235777 show
as 192.168.1.1, and a host the browser would reject is refused as
malformed. The Unicode form isn't repeated in the prompt, so a
whole-script lookalike can't read as the real site.
url, idna and icu_properties become direct dependencies of
pdfcraft-engine; all three were already in the graph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Security: refuse document addresses the URL parser rejects
Follow-up to #104. egui opens links through the webbrowser crate, which
parses the address with url::Url and, when that fails, treats it as a
local file path and opens file:///<cwd>/<address> in the browser.
document_url accepted addresses that don't parse, such as
mailto://a^b/x or https://a^b/ (a forbidden host code point), so a
document could still get the browser to open a local path.
document_url now also requires url::Url to accept the address with the
same scheme, which is exactly the parse webbrowser does first.
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>
* Security: pipe print jobs to lp instead of a shared temp folder
On macOS, Linux and FreeBSD, Print wrote each job to
<temp>/pdfcraft-print-<pid>/job-<time>.pdf and passed that name to lp.
The folder name is predictable and was made with create_dir_all, which
accepts a folder another local user created first in a shared /tmp and
follows a symlink planted there; the job was then written with fs::write.
That user could read every printed document, or swap the file before lp
read it. The folder was also never removed.
The job now goes to lp on stdin, so nothing is written to disk. lp_args
no longer takes a file. The job is fed on its own thread while lp's
output is collected, so neither side can block on a full pipe; if that
thread can't be started, lp is stopped and an error returned rather than
a panic. When lp refuses a job before reading it (an unknown printer)
the user sees lp's message rather than a broken pipe. CUPS ends its reply
for a job read from stdin with "(0 file(s))", which reads as if nothing
was sent, so that is dropped from the message shown.
submit_via takes the spooler command so tests can run this test binary
as a stand-in lp on every platform; the new test failed on the old code
(it received the temp file as an argument and nothing on stdin).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Security: create a new digital ID file fresh, owner-only
Configure New Digital ID > Create picked the first free name with
`!p.exists()` and then wrote the .p12 with fs::write. That is
check-then-write: a file or link planted at the name between the two is
written through. exists() also follows links, so a dangling symlink
looked free and the private key was created wherever it pointed. The
file got default permissions, readable by other local users on most
Unix systems.
save_new_id_file now creates the file with create_new, never opening
anything already at the name, and with mode 0o600 on Unix. A taken name
moves on to the next ("<name> 2.p12", ...), including a folder, which
Windows reports as access denied rather than already exists. A failed
write removes the partial file.
The dangling-link test failed on the old code (the key was written
through the link); the hard-link and folder test guards the retry loop,
and a Unix-only test checks the 0o600 mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Security: never write the --control token through a planted link
write_control_file opened the --control path with create + truncate. A
symlink or hard link already at that path was followed: its target was
truncated and overwritten with the control token before set_permissions
ran, so the token could land in a file another user can read, and any
file the link pointed at was destroyed.
The JSON now goes to a fresh file next to the path, with a random name
(create_new, 0o600 on Unix, another name if one is taken, including a
folder on Windows), which is then renamed over the path. The rename
replaces a link instead of following it, a control file left by an
earlier run is still replaced as before, and a reader polling for the
file never sees it half-written. A failed write or rename removes the
temporary file.
The hard-link and symlink tests failed on the old code (the token was
written into the linked file); a third test covers replacing a previous
run's file, and the owner-only mode on Unix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: credit jcl-bit as James Locke
My own entry in contributors/people.toml (AGENTS.md, Contributor
credits), added in my PR as asked there.
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>
Combine, extract and split built the new document from copies of the
pages and carried bookmarks, fields, layers, attachments and four Info
keys, but not the catalog's /OutputIntents, the GTS_PDFXVersion,
GTS_PDFXConformance and Trapped Info entries, or the XMP pdfxid
identification. A PDF/X file stopped being one, and a RIP or preflight
lost the intended output condition.
Extract and split now carry the output intents (with their ICC
profiles, through the same object map as the pages), the Info entries
(the version read from Info, else XMP) and a new XMP packet holding the
identification, trapping and title. The source's packet is not copied:
it can claim standards the new file no longer meets, such as PDF/UA,
whose structure tree is not copied. Combine carries the first source's
standard only when every source declares the same version, trapping
and output intents (type, condition and profile bytes).
Not yet: a warning when combine's sources disagree, and XMP dates.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Wrapping the title (#161) kept the prompt narrow, but a long enough name
still grew it taller than the window and pushed Save, Cancel and Don't
save off-screen. On the web a `?file=` URL names the document, so the
name has no length limit.
Past 80 characters the name now gives way in the middle ("start…end"),
keeping the start and the extension and version suffix; the full name
shows on hover. The new test checks the title and buttons stay inside
the desktop's minimum window (820×520) for long Latin, Thai and CJK
names and a 2000-character one.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M4: Combine files in its own tab, with a sortable table and unlocking of protected files
Combine files was a modal dialog with a cramped list and up/down buttons on
every row. It is now a tab next to Home and the documents, showing a table
of the files: File name | Pages | Size | Modified | Warnings.
- Columns sort when their heading is clicked (anywhere in the cell; again to
reverse; names in natural order), resize from their edges and move by
dragging the heading. Order and widths are kept in the settings; a
right-click on a heading resets them.
- Rows select like a file manager (click, Ctrl/Cmd+click, Shift+click,
Shift+arrows, Ctrl/Cmd+A). One toolbar moves, removes and unlocks the
selection; Alt+arrows and dragging move it too. Every change to the list
undoes and redoes with Cmd/Ctrl+Z (also toolbar buttons).
- Add files, Add folder, Add folder and subfolders (a split button), Add open
documents, or drop files and folders on the tab.
- Problems show before Combine: password-protected files, security settings
that withhold page assembly, page ranges (checked as typed, with the pages
they take), duplicates. Files that open but are protected say that the
combined file won't keep their security; the result is never encrypted.
- Unlock... asks for a file's open or permissions password (one password can
be tried on several files). Passwords stay in memory, are never printed,
and reach the engine through combine_unlocked; doc_combine takes them as
`passwords`. The engine decides whether a password works, since the
inspector rejects some it accepts (RC4 owner passwords).
organize.combine-sort is shipped and organize.combine-options partial
(include open files). Strings are translated for es, fr, ja, zh-hans, zh-hant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Credit Marlon Konrad (mlkonrad) in the About window
Adds my own entry to contributors/people.toml, as docs/contributors.md asks.
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>
* 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>
* 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>
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>
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>
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>
`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>
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.
* 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>
* 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>
* 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>
An unwrapped title carried a long file name past the viewport: egui's Modal
sizes to its content, so the centered panel grew to the name's full width,
clipped its own message and pushed Save/Cancel off-screen (reported on
Windows, reproduced everywhere). Wrap the title to the dialog width in the
save prompt and the password prompt, and pin the title and all three
buttons inside a 1365x719 screen in a regression test.
Co-authored-by: Mawfyy <mawfy12@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Signatures: RFC 3161 timestamp tokens (PAdES B-T) behind a transport-free TSA interface
* Signatures: document timestamps, DSS/VRI evidence embedding, trusted verification time
* Signatures: revocation evidence verification (CRL and OCSP) wired into validation
* Signatures: real-world certificate compatibility (CRLDP, tolerant extensions) and XFA-aware DocMDP classification
* Signatures: RFC 6960 encodings, delegated-responder verification, TSA trust anchoring, try_oid overflow
* Signatures: harden PR #69 validation (XFA, metadata, revocation, TSA trust, token digest)
- Remove the XFA "form fill" reclassification: it read the new revision, so
an update could add /XFA + /NeedsRendering and replace the page tree while
approval and P=2/3 certification signatures stayed Valid. Classification
(including catalog /Extensions) is back to main's.
- A changed stream tagged /Type /Metadata counts as metadata only when it is
the signed catalog's /Metadata stream or was already metadata when signed;
a relabelled page Form XObject is "other changes".
- A verified revocation stays Invalid when the certificate was also outside
its validity at signing time (was softened to Unknown).
- An embedded timestamp whose TSA does not chain to the trust store is
reported as an unverified timestamp and no longer sets the validity or
revocation time; the signer's claimed time is used, as on main.
- parse_token checks the TSTInfo messageDigest with the SignerInfo digest
algorithm, not the imprint's.
Each fix has a regression test in crates/sign/tests/pdf.rs that fails on
the PR head.
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>
* i18n: Czech interface language
Adds Czech (Čeština, code `cs`) next to English and Japanese:
Preferences ▸ Interface language, `ui.set language cs`, `ui.state`
and persisted preferences all accept it.
- i18n: `Language::Cs`; `tr()` picks the language's table with a match
(English has none), panic-free. The CZECH table (83 entries) covers
every label passed to `.tr("…")` (main menu, View zoom/theme/side-panel
entries, Preferences dialog), every registered File/Edit/Pages/View/Help
command shown by `registry_menu`, and every key of the Japanese table.
Written clean-room from the English meaning, not from any Acrobat
localisation.
- Tests: Czech covers every registered menu title and label, every
`.tr("…")` literal found by scanning the crate's sources, and the
Japanese keys; nothing is left identical except an explicit KEEP_AS_IS
list ("OK"); ellipses match the source; keys are unique per language;
`cs` round-trips through set_option, ui.set/ui.state and persistence.
- theme: the font setup is split into `font_definitions()`, and a test
checks with skrifa (dev-dependency, already in the tree via egui) that
Inter Regular/Medium/SemiBold and JetBrains Mono draw all Czech letters
and every Czech translation, so nothing falls back to Shippori Mincho.
- The `language` error lists `cs`; docs/localization.md, the parity note
for misc.prefs-language and the ROADMAP log mention Czech.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* 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>
* M14: add Traditional Chinese (Taiwan) UI localization
Co-Authored-By: Codex <noreply@openai.com>
* M14: translate shortcuts and expose language preferences in Edit menu
Add command-comma and verify language switching through the actual selector.
Co-authored-by: Codex <noreply@openai.com>
* M14: expand Traditional Chinese dialogs, panels and diagnostics
Preserve opaque values in translated templates and keep application diagnostics at the UI boundary. Extend native control and template regression coverage.
Co-authored-by: Codex <noreply@openai.com>
* M14: finish Traditional Chinese signature prompts and history labels
Translate generated action labels without changing filenames or field names. Complete checkbox styles, resolution labels and native picker filters.
Co-authored-by: Codex <noreply@openai.com>
* M14: complete Japanese dialogs, panels, shortcuts and diagnostics
Expand Japanese from 49 to 1830 interface entries, expose language preferences in the Edit menu, preserve opaque template and history values, and localize known application diagnostics only at the UI boundary. Add control, persistence, template and desktop/web glyph regressions.
Co-authored-by: Codex <noreply@openai.com>
* i18n: Simplified Chinese UI language option
Adds Language::Zh alongside en/ja (--language zh, preferences switch,
persisted): full Chinese translation table covering every user-visible
label, generic Hans font fallback plumbing, and a build.rs skip-guard
plus test so Noto CJK is never embedded (AGENTS.md 1.1). Pairs with
storytold/craft-fonts#5 (ChillRoundF Regular, OFL-1.1 Adobe-free
chain), which supplies the Hans glyphs. Also fixes a startup deadlock
from translating inside lazy widget_info closures.
* 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>
* M14: Czech as a TSV catalog
Move #68's Czech table into crates/ui-egui/src/i18n/cs.tsv (PrintCraft
labels renamed PdfCraft, plus Auto) and register it with PhotoCraft's
Czech plural rule (one / few / other). #68's tests become catalog
tests: Czech covers every registered menu title and menu command label,
keeps ellipses, uses no three-dot ellipsis or stray whitespace, and only
leaves "OK" untranslated; the code persists and `cz` is rejected. The
source-scanning `.tr("…")` test is dropped: strings now go through tl!
and the catalog tests check consistency.
The font-coverage test moves to tests/fonts.rs and covers both Latin
catalogs (cs, pt-br) against Inter and JetBrains Mono. It keeps skrifa
as a dev-dependency (the version pdfcraft-fonts already uses): egui's
has_glyphs can't answer it, since a family with only the primary face
also uses that face as its replacement face.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M14: route every UI string through the catalog; Japanese as ja.tsv
Port #141's wiring to the catalog API from land/106:
- `crate::i18n::tr(ui, s)` becomes `tl!(s)`, `tr_fmt`/`tr_template`
become `i18n::fmt(tl!(..), ..)`, and `Language::get`/`store` (the
egui temp-data copy of the language) are gone: the drawing language
is the per-thread `i18n::current()`, set from the preference at the
start of each frame. Worker threads (export, OCR, batch actions) take
the UI's language with them for their summaries.
- ja.rs (1,830 pairs) becomes ja.tsv, merged with the existing entries
and renamed PrintCraft -> PdfCraft. The 29 diagnostic templates of
errors.rs are dropped.
- `Language::diagnostic` and errors.rs are dropped: matching engine and
OS messages against English templates breaks silently whenever the
wording changes. Error details are shown as they are, inside the
translated frame ("{label} failed: {e}").
- History labels (`command_label`/`action_label`) move into i18n and keep
captured file and field names; registry menus prefer `@id` entries.
- Preferences: Auto plus every registered language, relabelled at once.
- Tests: Japanese covers every command and All tools entry; Japanese
labels have glyphs with the desktop faces and with the web subset
(with craft-fonts); history labels; #141's control tests, with the
error-detail expectation updated.
Co-Authored-By: shuiyue-cmyk <291597411+shuiyue-cmyk@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M14: Preferences in the Edit menu, Cmd/Ctrl-comma
Split out of #141: register app.preferences in the Edit menu with the
platform's usual shortcut (Command-comma on macOS, Ctrl-comma
elsewhere), so the interface language can be found and changed with no
document open. Shortcut keys may now be punctuation (egui's key name
","). #141's control test drives the menu item, the language selector
and the shortcut in Japanese and English.
Co-Authored-By: sakamoto824 <sakamoto824@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M14: Traditional Chinese as zh-hant.tsv
Reduce #140 to its translations: zh_tw.rs becomes
crates/ui-egui/src/i18n/zh-hant.tsv (PrintCraft renamed PdfCraft; the
29 errors.rs diagnostic templates dropped, as for Japanese) and one
registry row, code `zh-hant` (Taiwan vocabulary). Locales zh_TW, zh_HK,
zh_MO and zh-Hant-* resolve to it. #140's own copy of the UI wiring and
its errors.rs diagnostics are not taken: land/141 carries the wiring.
Tests: registration, locale mapping, every command label translated.
The catalog is an attributed asset; docs, ROADMAP and parity list it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M14: Simplified Chinese as zh-hans.tsv
Reduce #112's translations to crates/ui-egui/src/i18n/zh-hans.tsv
(PrintCraft renamed PdfCraft) and one registry row, code `zh-hans`
(#112 used `zh`; `zh`, `zh_CN`, `zh_SG` and `zh-Hans` locales still
resolve to it through the system-language match). #112's own copy of
the UI wiring is superseded by land/141. Tests: registration, locale
mapping, menu coverage. The catalog is an attributed asset; docs,
ROADMAP and parity list it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M14: CJK font order follows the UI language
From #112: `ui_cjk_fonts(prefer_hans)` orders the craft-fonts CJK faces
for the interface, and `theme::font_definitions_for` installs them. In
Simplified Chinese the `Hans` faces come first; otherwise the Japanese
ones (the previous behaviour). egui draws each character with the first
face that has it, so with Japanese first a Simplified-only character
(欢) would land in another face and sit on a different baseline. The
fonts are reinstalled when the language crosses that line (same family
names, so named fonts stay valid while the new set loads).
Changes from #112: within each group BIZ UDPGothic still leads, so a
serif listed first in the manifest (Shippori Mincho) never becomes the
Chinese-mode UI fallback; the unused `document_chinese_font` is left
out; and the crates/fonts/build.rs special case that skipped
"Noto Sans CJK SC" (and its test assertion and README line) is not
taken. Without CRAFT_FONTS_DIR, or without a `Hans` face, the order
logic yields only what exists: Chinese characters then show replacement
boxes and nothing panics (tests/fonts.rs, the fonts crate test, and the
control test switching ja -> zh-hans -> en).
Also document `--language` on the app's command line.
Co-Authored-By: shuiyue-cmyk <291597411+shuiyue-cmyk@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Petr Leo Compel <petrleocompel@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Simon Leblanc <contact@leblanc-simon.eu>
Co-authored-by: sakamoto824 <sakamoto824@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: shuiyue-cmyk <291597411+shuiyue-cmyk@users.noreply.github.com>
Co-authored-by: RicSchonfelder <ricschonfelder@icloud.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com>