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.
A checkmark, cross, dot or line placed on a page with /Rotate was drawn
in the page's own axes, so on a page turned 180 degrees the checkmark
came out upside down, and on a quarter turn it lay on its side.
Marks now record the page rotation (PCStampRotate, as rubber stamps do)
and their appearance is drawn upright in the displayed box and mapped
into /Rect, sharing the stamps' code for it. The mark's box is sized in
displayed axes (FillMark::rect_at), used by both the Fill & Sign tool and
fill_sign_add, so a line runs across the page as displayed.
A date the field's data picture writes as 20261018 reaches scripts as
2026-10-18, the rawValue another viewer gives them, so a script that
checks String(rawValue).length == 10 works (DA Form 31's 10b TO).
Doc comments on the functions this branch touches.
Fields keep their <format><picture>. A date display picture turns the
ISO value into its pattern for scripts' formattedValue (JavaScript and
FormCalc), so date arithmetic on formattedValue works as forms expect.
Once a script sets the value, formattedValue gives that value. Number
and text display pictures still give the raw value.
A whole-document copy reads its pages off the UI thread, and a copy made
meanwhile (a page selection, a text field's, a comment's, an image) went
to the clipboard first and was then replaced by the document's text.
Any later copy now drops the pending one: copy_selected_text and the
image copies drop it directly, and finish_document_copy, now run at the
end of the frame, drops it when anything else copied that frame.
DocumentCopyJob::of classifies each page from text_failed (a set)
instead of looking it up in pages_without_text's Vec, which was
quadratic in the page count.
Opening the Print dialog reset the printer to the system default every
time, while copies, range and paper carried over, so Save as PDF or a
non-default printer had to be picked again for each print.
The dialog now keeps the printer picked last time, Save as PDF included,
while it is still listed. A fresh list that no longer has it (the
printer was removed) falls back to the default.
* fix(redact): evaluate OCMD visibility during sanitization
Fixes#3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively.
* fix(redact): preserve OCMD visibility during sanitization
* fix(redact): count hidden XObjects during verification
* test(redact): cover hidden form depth limit
From review of #3756: add_resources set /PCTimes (or /PCCour, /PCHelv)
in the page's fonts whatever was there, so a page that already drew
another font by that name would have its own text change face. Headers
and footers now pick the usual name when it is free or already names the
same standard face, otherwise the first free name_1, name_2…, and add
only that face, never replacing an entry. An update finds its own name
again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headers and footers were Helvetica only. HeaderFooter takes the same
standard-face Family as Add text: the content selects /PCHelv, /PCTimes
or /PCCour, the page gains that font, and centred and right-aligned text
is placed by the chosen face's standard-14 widths. doc_header_footer
takes font, and the dialog's fixed "Font: Helvetica" label becomes a
Helvetica / Times / Courier menu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review of #3749: the hit test alone would pass with a fixed
12-pixel preview. Read the preview stroke from the frame drawn mid-drag:
12 pixels at the default 2 pt, 72 at 12 pt, and gone once released.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review of #3749: the user-space radius had a 1-unit floor, so at a
high zoom the eraser rubbed past its preview; and zoom was read from the
screen rectangle's width, which is the page's height when the view is
turned a quarter. PageXform::zoom reads the side the page's width is on;
the eraser and the drawing preview use it, and the floor is gone. A page
with no size on screen erases nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>