The field's /TU is read into FormField::tooltip and shown in the Fields
panel, but resting the pointer on the field on the page showed nothing.
page_input now attaches it to the hover, as Acrobat does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ
* Release: PdfCraft v0.11.0
- Bump the workspace to 0.11.0.
- Fix main's red CI (cargo-deny): update rustls-webpki 0.103.15 -> 0.103.16
(RUSTSEC-2026-0382) and webpki-root-certs 1.0.9 -> 1.0.10 (RUSTSEC-2026-0383),
both reached through winprint's sigstore build dependency.
- `cargo xtask version set` now moves the workspace crates' pins in
[workspace.dependencies] with the version; before, `cargo update --workspace`
refused the bump and the pins had to be edited by hand.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: a healthy private retry may answer during the watchdog pass that scheduled it
shared_timeouts_retry_healthy_pages_privately_before_blacklisting failed on
CI (PR #3893): the try_recv that runs the watchdog spawns the private retries,
and page 1's retry has no gate, so on a loaded runner its healthy result
arrived before that same call read the channel. Accept that early answer
(page 1, no error) and use it as the healthy result; an error still fails.
Reproduced by sleeping 300 ms after the watchdog in try_recv: the old test
fails every time, the new one passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
FredApps: CI never fetches the OCR models, so the tests that recognize something
skip there and the recognition paths this PR adds went untested in CI.
The decision is split out of auto_ocr_scanned_page into scan_to_read, which
needs no models (the run itself judges the page's own text), with unit tests
that run everywhere: a picture page is read and a text or blank page is not;
only Edit being on starts it (its tab counts); Read mode and an open dialog
wait; a page already read is not read again and a page found no scan is judged
again once the document changes. Two mutants (no Read/modal guard; a no-scan
page never judged again) each fail one of them. The tests that recognize words
still need the models.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Proposed for the maintainers to accept or drop (separate commit): the page
viewer, on screen only, may use one installed face for a CID font of an Adobe
CJK collection that a PDF does not embed. Exports, printing, OCR and the
automation tools never use it; nothing drawn with it is saved or printed; no
Noto CJK, Source Han or Adobe family is a candidate.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
FredApps: AGENTS.md §1.4 allows reading an installed font at runtime only as
the interface's last fallback and says nothing from it may enter a PDF or
package, while the renderer fallback also served exports and printing.
RenderConfig gains system_fonts, off by default. The engine sets it only on the
pool that draws the viewer (display_config); exports, printing, OCR and the
automation tools render with the document's own configuration, so nothing drawn
with an installed font is saved or printed. Tests: the render of a non-embedded
CNS1, GB1 and Korea1 font is blank without the flag and drawn with it; the
viewer's pool draws such a page and an export of it does not (it fails if the
flag is on everywhere). No Adobe-named font can be a candidate.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The preview was always monochrome, so the Print in grayscale checkbox appeared to do nothing but flicker between the colour thumbnail and the (wrongly) greyed sheet raster.
Move GRAYSCALE_TAG to a free tag bit (1 << 59) so it no longer overlaps PRINT_TAG, and let the colour and grayscale preview requests carry different tags so set_print_grayscale actually re-renders the right one.
Test: print_preview_tag_distinguishes_grayscale_from_colour failed before the change and passes after.
A TextSession opens a paragraph once: it keeps the document as it was
and the page's lines then. Each draft is laid out on a copy of that
document, so no draft sees another's output, and the commit writes into
the document it was opened on, refusing ("the paragraph changed while it
was being edited") when that document has changed since.
text_edit_layout and rewrite_block now open a session and lay out or
commit once, so there is one path and their output is unchanged. No
caches yet: those come next, each under the same equivalence test.
First step of #2512.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two cases kept the old picture on screen after a move or resize:
- A page that had no tiles when the edit applied was taken as drawn
again as soon as a first tile arrived, while its backdrop still had
the picture. Out of date now means its whole-page raster or any tile
still waits for a replacement.
- A second drag before the page was drawn again took the picture's
pixels from its new frame, where the raster doesn't have it, and
blanked only that. It now takes them from where the raster still has
the picture and blanks that, and keeps it as the source on release.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The look of an applied paragraph ended with the first whole-page raster.
On a tiled page the stale tiles are painted over that raster and can
still carry the old text. End it only once the page is no longer drawn
from before the edit: its whole-page raster (a tiled page's backdrop
too) and every one of its tiles has been replaced, whichever arrives
last.
Keep the page's turn on screen with the look, too: after the view is
turned, its position would be misplaced, so the raster shows instead,
as after a zoom.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applying a paragraph takes the editor away before the edit runs. When the
edit failed (a letter no font can draw, for one), the error was shown
and the typing was gone with the editor. A refused form field already
opens again with its value.
Keep the editor whose edit was queued (applying it, or clicking into
another paragraph). If that edit fails it opens again with what was
typed and takes the focus, instead of the box the click moved to; a
successful edit drops it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bookmark made in the app always went to the top of the page. It now goes to
the top-left of the selected text, or with nothing selected to the top of the
window, as /XYZ left top null: the reader's zoom is kept, and with only the top
pinned so is the horizontal position.
- render: PageInfo::xyz_for_view, the inverse of dest_fraction. It pins only
the coordinates given; on a page turned a quarter the displayed y decides the
user left and the displayed x the user top.
- organize: add_bookmark_at and OutlineError::BadPosition (a coordinate that is
not finite is refused before anything changes).
- engine: Edit::AddBookmark takes left and top (None, None is the page top, as
before).
- automation: bookmark_add takes top and left in points from the top-left of the
displayed page, as bookmark_list reports them, bounded and refused when not a
usable number.
- ui: DocView::bookmark_position. It follows the window's vertical when the view
is turned a quarter (View > Rotate View), picks the selection's corner that
shows at the top-left, and in a spread of unequal pages uses the page that
crosses the window's top edge.
- parity: organize.bookmark-add evidence and what is still missing.
The page without an added item is rendered once per gesture so the
item's pixels can follow a move (MoveBackground). A corner resize got
none of that: only the outline followed the corner, the picture stayed
at its old size under it for the whole drag, and showed there again
after the release until the page was rendered.
Give a picture's corner resize the same background, and stretch its
pixels from the box it had into the dragged box, then into the applied
box while the page settles. Text resized by a corner rewraps, so it
keeps the outline only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dragged page image is drawn from the page raster, blanked where it was
and stretched into its new frame. On release the edit applied and the
drag state cleared in the same frame, but the page raster on screen
still had the picture at its old size and place until its replacement
arrived. For those frames the old picture showed again around the new
frame, then jumped.
Keep the old and new frames on the selection after a release, and draw
the picture the way the drag did while the page is out of date: its
raster, or on a tiled page any tile, still waits for a replacement. A
failed edit never makes the page stale, so nothing is kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applying a paragraph rewrites its page, but the page raster on screen
still has the old text until the page is rendered again. The textless
background and the editor went away on the frame the edit applied, so
for those frames the old text came back under the paragraph's new
outline, and then jumped to the new one.
The editor now remembers how it drew its text each frame. When the
edit that closes it is that paragraph's, the textless page and that
last look stay on screen until the page's new raster arrives, as
MoveBackground does for moved items. Any other edit, a zoom, or the
raster ends it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The paragraph editor is an egui Area, and Areas fade in over the style's
animation time (0.2 s) each time they appear. While the editor faded, so
did its cover and its frame: the page's own copy of the paragraph showed
through it under the editor's text, and the frame pulsed from dim to
full. Every click into a paragraph did this.
Show the in-place editors at once: the paragraph editor, the editor for
text added in this session, the Fill & Sign text box and the form field
editor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joining runs into one line compared their character and word spacing to
1e-6. Word documents exported through macOS Quartz write 0.0001 Tc on
some words, so a line like "for company AEROSOL" split into "for
company A" and "EROSOL", two paragraphs that edit separately.
Spacing below a thousandth of an em, the unit glyph widths are given in,
can't be seen. Compare character and word spacing to that; scale and
rise still have to match exactly, and visible spacing still keeps runs
apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three of them, measured on main:
pdfcraft-cli extract f.pdf --pages 1 --out --password
(exit 0, and the PDF is in a file called `--password`)
pdfcraft-cli check-one --edit f.pdf
pdfcraft-cli: check-one: missing file
pdfcraft-cli info /tmp/café.pdf (that é as one Latin-1 byte)
thread 'main' panicked at library/std/src/env.rs:878:51:
called `Result::unwrap()` on an `Err` value
- An option whose value is a **file or folder** refuses a value that is another
option, and names the way out for a file that really starts with `-`:
`./--name`, as `--root` already offered. That also closes the gap the option
walk left for those: an unknown option in a value position was skipped along
with the option it followed, so `combine --out --nonsense` wrote `--nonsense`
instead of saying what `--nonsense` was.
Only those options. A password or a title can be any string, `--page`
included — `an_option_shaped_password_is_not_parsed_as_a_page_flag` pins
that, and a bad `--page` or `--dpi` is reported by the command in its own
terms either way.
- `positional` asks the option table which tokens are values, instead of
skipping the one after every option. The token after a flag that takes none
is an argument: `check-one --edit f.pdf` read the file as `--edit`'s operand
and then reported it missing.
- Arguments come through `args_os`, so one that is not UTF-8 is a usage error
rather than a panic. `std::env::args` panics while collecting them, so even
`--help` ended that way.
tests/options.rs covers each, and each fails without its fix (the file called
`--password` comes back; "check-one: missing file"; exit 101 with that report).
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
with_missing_appearances_cached checked each /Annots entry against the source document, which never gets the appearance, so an annotation listed K times added K appearance streams to the display copy. The engine writes that copy on open and after every edit, so the display bytes grew with the number of listings: 20,000 listings of one Ink comment in a 136 KB file came to 428 MB.
Each annotation reference is now handled once per call, across all pages.
The XFDF export nested one <field> element per period-separated part of a field's full name. A /T can hold periods of its own, so a name can have far more parts than the field tree has levels. Past 64 levels the import refuses the file the export wrote, and around 100,000 parts the recursive write overflows the main thread's stack and aborts the process.
The export now nests at most as many levels as the import reads back. The parts past the last level stay in the deepest element's name, and the import joins them back into the same full name.
With junk before %PDF the table's offsets are relative to the header, so the "1 G obj" check is also tried there, as load does; otherwise object 1 stayed free and a full save dropped the image.
The Header and Footer dialog (also opened by Bates numbering) gets a
checkbox that turns on the shrink, and its preview shows the band the
page content shrinks into. The label is translated in every catalog (the
catalog checksums in ATTRIBUTION.toml follow). The parity entry for the
shrink is shipped, with its limits: annotations and added text and images
are not moved yet.
The tool passes shrink through to the header and footer, with an end-to-end
test that reads the moved text back from a saved copy, refuses margins that
leave no room and checks that Remove brings the content back to full size.
HeaderFooter::shrink scales the original page content evenly into the
band the header and footer leave free, centred across the page, in
display space so rotated pages shrink the way they are shown. The scale
is a content stream first inside the q/Q wrapper, tagged as part of the
header and footer, so Remove and Update take it away again and
backgrounds behind the page stay full size. Margins that leave no room
are refused before anything is drawn.
import_pages_with and insert_file_pages took the objects numbered above the
destination's highest number for the ones the import added. When no number
is left above the highest (an exhausted /Size, or object 4294967295),
Document::add fills free numbers below it: the copies skipped resource
sharing, and a refused import left its objects in the destination. With
object 4294967295 the `n + 1` overflowed, a panic in debug builds and in
release a wrap to 0 that counted every object as added.
Record the object numbers before the import and take the ones present
afterwards but not before.
CodeRabbit on #3788: the handles and outline of a selected line, connected lines, polygon or
cloud followed a dragged end but not a drag of the whole comment, which kept its old handles
until the page was drawn again (the frame such comments had before showed the move). The
points now follow a body drag and the released move as well (changing_points, one rule for
reshaping and moving). Test: a_moved_or_reshaped_line_is_drawn_at_its_new_points.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a Finnish (Suomi, `fi`) catalog with 2,592 entries: every key of
the other bundled catalogs, written in sentence case from the English
labels. Finnish uses the one/other plural rule with the partitive
singular after any number but 1 (1 sivu, 2 sivua, 0 sivua).
Finnish is also a date language: calendar headings use the nominative
month (Lokakuu 2026) and dates the partitive (14. lokakuuta 2026).
Any fi-* locale selects it.
Tests cover registration, locale tags, persistence, plurals, calendar
wording, commands/tools, scanned UI literals, every bundled catalog key
and the Latin UI fonts. The catalog has its ATTRIBUTION.toml entry
(ATTRIBUTION.md regenerated with `cargo xtask assets --write`).
Clean-room, AI-assisted (Claude Code) translation; not yet proofread
line by line by a native speaker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review of #3756: a /PCTimes naming Times-Roman with more to it (a
/ToUnicode map, /Widths) passed as the same face and was replaced, so
the page's own text could lose its map. A name is reused only when it
holds exactly the dictionary a header writes, and then it is left as it
is; anything else gets a free suffixed name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FredApps' approval on #2359 carried one non-blocking nit that is a real defect: the header told
contributors to "regenerate with make-oracle-fixtures.py in the workspace root" - a script that
exists only in my checkout, next to a venv (.dc-tools/) likewise outside the repository. A
contributor following it finds nothing, and this repository has no committed generator for these fixtures
(crates/oracle treats qpdf strictly as an external process, and CLAUDE.md keeps the product and build in
Rust).
The header now carries a self-contained recipe, measured on this host rather than asserted:
* one blank 200x200 page, pikepdf 9.11 / qpdf core 12.2.0, Encryption(owner=OWNER,
user=USER, allow=Permissions(extract=False), R=<revision>, aes=…, metadata=…),
save(static_id=True), then read /Encrypt. At revisions 2-3 `aes=False, metadata=False` are
FORCED - pikepdf refuses AES ("Cannot encrypt with AES when R < 4") and refuses encrypted
metadata ("Cannot encrypt metadata when R < 4") - so leaving those flags to the reader makes
the export-grade row unreproducible. My first draft of this comment made exactly that mistake.
* Reproduction scope, by revision: each of the four deterministic rows quotes eight dictionary
fields (32 per comparison leg). Revisions 2 and 3 reproduce as whole files - fresh writes today
carry the same sha256 (63f2d95b…, 39afbdd5…) as the October originals these literals came
from. Revision 4 reproduces all eight fields, but its file moves by exactly one ~32-byte
stream because /CFM /AESV2 prepends a fresh random IV (ISO 32000-2 §7.6.4); the dictionary
and /ID are unchanged. Revision 6 reproduces structurally only (/ID, R/V/Length/P and the
metadata flag); its salts are new each run.
Running the generator at the reviewer's prompting exposed that it had not been executed since its
last edit: unless both aes=False and metadata=False are forced pikepdf refuses R<4, checking metadata first, so it wrote 4 of the 6 fixtures - revisions 2
and 3 missing, including this file's headline export-grade row - while printing a "skipped:" line
and exiting 0 regardless. It writes 6 of 6 now, and every quoted field reproduces as above.
The overstatements flagged during review are corrected in this file; they are quoted verbatim,
each with the basis for its new wording, in the PR description rather than here - commit messages
cannot be edited after a push, descriptions can. Two further fixes live in this message alone: an
ISO subsection written as §7.6.4.3, a form this repository does not cite (it uses bare §7.6.4 or the
deeper §7.6.4.x.y), and one clause duplicated mid-cycle.
What nothing here verifies, stated instead of implied - established by deliberately breaking the
table (4 mutations: 2 red, 2 green): authentication never reads /Perms (a corrupted one fails
only this file's own decrypt), and renaming a row's /StmF to Identity or replacing its
crypt-filter set leaves all three tests green, so the filter names are transcription held to
review. Pinning them, and re-deriving these rows through pdfcraft_oracle::qpdf() on the runner
that installs qpdf, belong in a follow-up. Also recorded honestly: the revision-6 fixtures these
rows were copied from are gone - my own regeneration overwrote them before I archived the folder -
so those two rows rest on internal consistency plus this record, not a surviving artifact. Revisions 2 and 3 carry their own proof, byte for byte (sha256 63f2d95b… and
39afbdd5…, October and today); revision 4 reproduces every quoted field, with only the one
IV-carrying stream moving.
Scope: comments plus one assertion message (a revision 3 /U description that wrongly claimed qpdf
leaves sixteen zero bytes; its condition, len == 64, is unchanged). No assertion logic, fixture,
dependency or product code touched. Parent remains the approved cdbce8ac, so this lands as a
fast-forward rather than a rewrite of the head FredApps reviewed. Gates at this tip
(upstream/main 22bedbf2; branch 326 behind, trial merge clean): cargo fmt --all -- --check rc=0;
clippy -p pdfcraft-crypt --all-targets -D warnings rc=0; cargo test -p pdfcraft-crypt
--no-fail-fast 28 passed / 0 failed (17 lib + the 8 from #2358 plus these 3); cargo xtask layers
OK, 37 crates; cargo xtask assets ok (272 repository, 6 bundled by dependencies, 22 fetched at
build time). CI had not run on this tip when this was written - the commit was local.
Co-authored-by: Qwen3.8-Flash-Next-oQ4e-WLSRefit-MTP <noreply@qwen.ai>
Co-authored-by: DeepSeek V4.1 Flash <noreply@deepseek.com>
2026-1-2 isn't an ISO date and stands as it is. A test also pins that
parentheses hold a picture's locale (date(en_US){YYYYMMDD} formats) and
that a clause without braces has no pattern.