The pages' presses are the primary button's (`PagePointer.edges`, the
router's `began`), so a right-button drag was a press nobody held, and
every tool that started on egui's `drag_started()`, which counts every
button, started on it: the Rectangle tool drew a rectangle, Prepare's
selection rectangle selected the fields in it, the Link tool opened Link
Properties for a new link, the camera moved its quadrilateral to the page,
and the Select tool selected text. On `main` too.
`PagePointer.drag_started` is a primary drag of a press the pages hold,
started this frame. The page modules read it instead of egui's (comments'
shapes, callout and text box, Prepare, the Link tool, the camera, the text
layer, image pick, and the Edit text group, whose owned presses were
primary already); zoom, crop, Sign area and Redact already asked for the
primary button. The guard refuses a bare `.drag_started()` in a page
module (`NOT_PAGE_DRAGS` lists the widgets of their own: Organize's
thumbnails, the signature pad, the Scan tab), and the router's
`drag_started` is `drag_began`, so the rule reads cleanly.
Tests, one per family, each seen failing with the fix reverted:
`a_right_button_drag_draws_no_rectangle`, `…_selects_no_field`,
`…_draws_no_link`, `…_leaves_the_camera_quadrilateral`,
`…_selects_no_text`; the guard rule against a planted `#[cfg(any())]`
call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It hovered fifty times with a 10 ms sleep between, so a busy runner
could run out of tries before the page's text or paragraph boxes
arrived from their worker threads. It now steps frames with
`support::step_until`, as the other waits for background work do,
until the cursor is the one wanted or 30 seconds pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the Edit text group, the text layer and link following told what
the press is to their page, only the page view reads the router.
- The router's methods and `DocView.router` are `pub(in crate::canvas)`:
outside `canvas` the compiler refuses a read or a write, aliases
included, which the guard's text match could miss. `ROUTER_READERS`
keeps only the page view's dispatcher (`page_tool_input`).
- Rule (f): no function in a page module is named with "grabs" or
"owns", which decide for themselves whether a press is theirs.
- CLAUDE.md's "Page presses" rule says so: modules never read the
router; they are told.
Each rule seen failing against a violation planted in `#[cfg(any())]`
code (a `router.keeps_drag()` read in `content_ui`, a `fn fill_grabs` in
`fill_sign`), and a real router read from `content_ui` no longer
compiles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A comment's gesture ended only on egui's `drag_stopped()`. When the pages
missed its release (the button let go where they didn't see it), the
gesture stayed live: it kept the page's frames from Fill & Sign and the
shape tools, and the next drag that stopped on the page committed it,
taking the comment there.
It now ends, as the other page gestures do, on `drag_stopped()` or once
the primary button is up, where the pointer is.
Two tests planted a live move with the button up to check that a live
move keeps its page's presses (`main`'s
`active_comment_gesture_keeps_highlighted_link_click` and 4a's
`a_fill_and_sign_click_places_nothing_while_a_mark_is_moved`). That
state now ends at once, so both hold the press through the planted move
instead: the intent stands, and each still fails with the live-move
guard taken out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The text layer asked the router whether the press's owner kept its drag,
link following asked it which link held the press, and the Link tool
started a new link from a press any link held, since the router kept a
declined drag's capture.
- `PageRouter::drag_started`: a drag the owner declines (text markup;
with the Link tool, a link) is nobody's once it starts, on the page it
began on. The Link tool draws a new link only from a drag nobody
holds.
- `PageInput` carries the press (`PagePress`) and whether its drag is the
page's text's (`text_drag`): the text layer and link following read
them, and `PageRouter::owner` goes. Only the page view reads the
router now.
- Image pick acts on a press nobody holds: a link's click follows the
link only, and picks no picture under it (the user's ruling).
- The I-beam drops over a link only where a press would go to it, the
top hit under the pointer (the user's ruling): the tools that select a
link's text show it there.
- `PageText::glyph_at` is the one test for the text under a point, for
the text layer and Redact's boxes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit text ignores input while a dialog, the palette, another window or
another tab has it. Only a drag of the objects selected together ended
then; a paragraph's move or rewrap, a picture's move, resize, turn or
crop and added content's move waited, and once the input came back with
the button up they landed wherever the pointer was.
While Edit text's input is blocked, `DocView::cancel_edit_text_gestures`
abandons them all, as the objects' already was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page's menu chose its entries from the selections, in a fixed order:
a selected Prepare field's menu, then a link's, then the picture's, then
a comment's. A selection left from before outranked what was just
right-clicked, so with a field selected, a right-click on the empty page
(or on a comment) opened the field's menu.
The right-click remembers what it went to (`DocView.menu_for`, from the
routed hits), and the menu shows that owner's entries when it has a
menu, or else the page's (the user's ruling). Add text's right-click is
the page's, as before; while a press is held only a link takes its menu.
The picture menu's flag and its geometric test go with it.
#5900's menu check (no image menu over the picture being cropped, #5835)
is the routed owner's too: a right-click that went to the crop (its
picture, box or grips) opens no menu, and `crop_contains_point` and
`crop_controls_contain`, its second point test, go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Edit text group still asked the router for itself and passed answers
from one module to the next: `others_own_pointer`, `text_used`,
`over_source`, `source_blocked` and the pictures' `allow_interaction`
decided who acted, so what the pointer happened to be over at the release,
or an earlier module's answer, could take a click from the module its
press was on.
- Paragraphs (`ParagraphCx`), pictures (`ImageCx`) and added content
(`ContentCx`) are told the press (`PagePress`) and Edit text's gate (its
input blocked by a dialog, the palette, another window or tab, as
before), and act on their own presses; the paragraph outline comes from
the top hit under the pointer (`hovered`), hidden while the objects
selected together or added content being moved hold the page. The flags
go, and the three modules no longer read the router.
- Add text acts on the paragraph its press was on, or the one under the
pointer; its click on a paragraph refuses a blocked draft and finishes
the one being typed in the paragraph's own act.
- Clicks act where they were pressed (the user's ruling): a paragraph
opens though the button came up just past its box, added text is
selected, and opened for typing, by the item its press was on, and Add
text's new box starts where it was pressed (a press beside a paragraph
released inside it starts a box; it placed nothing).
- The picture's grips and rotate knob lose their egui widgets: a drag
takes what the press was on. A press past a page's edge is the page's
that has something under it there (the knob of a picture filling its
page), so the knob still turns the picture there.
- Edit text's right-click goes to what lies on top under it, as every
tool's does (ruling C): a picture is selected for its menu through
`edit_text_ui::picture_context`, and a comment over a paragraph or a
picture gets its own menu. The page view's own picture scan goes.
- #5900's part of that scan (a right-click on the picture being cropped,
its box or its grips keeps crop mode, over a picture on top of it too)
is the crop hit's: `CropPart::Cut`, the picture outside the box kept,
shown or cut away, joins the grips and the box, so a right-click there
keeps crop mode. Its menu check stays for now (the menu commit takes
it). Test: `a_right_click_on_the_part_a_crop_cuts_away_keeps_crop_mode`,
seen failing without `Cut`.
- With the Edit panel open, the tools that don't pick added content up
still select added text by its own test at the pointer, as before: a
press-time hit would take the press from the stamps and the other tools
that place on any press.
- `main`'s press-handler check guards nothing now, and goes; rule (e)
(`ROUTER_READERS`) keeps the modules from reading the router again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`object_ui` took a Shift- or ⌘-press before anyone else, whoever owned it,
through its own inventory test at the press point, and declined a plain
press where that test found an object the router didn't (just outside a
picture's edge), so such a press started nothing at all.
- The page view reads the press's modifiers from `PagePointer`
(`modifiers`: those held at the frame's first primary press). With
Edit text, a press that adds (Shift, Ctrl or ⌘) is the objects' whole:
`object_ui::modified_hit` gives `Objects { Toggle(target) }` for the
object under it, or none (an area selection that adds), `Held` while
text being typed is open, and `Refused` on a page whose objects can't
be selected together. Only the press asks: hover and cursors are as
before.
- `object_ui` is told what the press is to its page (`PagePress`) and acts
on its own parts; a press nobody holds starts the area selection. The
inventory test and its decline go, and so does `blocked_page`: the held
press keeps the editor open until the button is up through its capture.
- A plain press beside a picture, within the 2 px the objects are found
in but outside the picture, now starts an area selection.
- The guard: `object_ui::input` no longer reads the router.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crop mode held every press on its page through a hit with no point test,
so its claim won the cursor on every visible page (its Default arrow over
the next page's paragraphs), and its own grip test at the drag's start and
its cursor test used different reaches (10 and 8 px).
- `edit_text_ui::crop_hit` says what of the picture being cropped lies
under a point: a grip of the box kept, the box itself, or the rest of
its page (`PicturePart::Crop`, on `Layer::Editor`), and the hand over
its Done, Cancel and Remove Crop controls, past the page's edge too.
A drag takes what its press was on (`ImageCx.press`), and the cursor is
the hit's: one reach for both.
- Delete and Backspace on a selected picture, and crop mode's Enter and
Escape, move to the app's key handling (`edit_text_ui::picture_keys`),
which no key reaches under a dialog. Delete works wherever the pointer
is (the user's ruling: before, it did nothing over a paragraph).
Escape while a button is down is still the press's: it cancels a crop
drag, and crop mode stays.
- #5900's `crop_controls_contain` (the right-click check for #5835)
measures with `crop_frames` and the grips' reach, so the crop's frame
and reach have one source; its `crop_bounds` was `crop_frames`' frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refinement whose reference is offset right of it (GRREFERENCEDX above
0, which a refined text instance has from a wider bitmap, ISO/IEC 14492
Table 12, and a refined symbol from an RDX above 0, Table 18) read the
reference's first 32 columns as 0. hayro-jbig2 emptied its buffers of
the reference left of it and, at its first column, didn't load them
again, so the refinement decoded to other pixels than were coded, or
failed and lost the page.
Vendored patch 15 (13 is #5999, 14 #6016): left of the reference the
buffers keep the words of its first column each row starts with. With
an offset of 0 or less nothing changes.
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* M6: forms decodes values, renamed calculations and detected pages within shared budgets
Part of #5755: the forms crate's four unbudgeted stream reads go through a
`DecodeBudget`, so an operation's decoding is bounded however often the file
names the same stream.
- A reading of the form decodes its fields' value streams (/V, /DV) within one
64 MiB budget, as it already does their scripts. Each field got a fresh
1 GiB allowance, so 300 fields naming one stream decoded it 300 times.
- Renaming a field rewrites the calculations that read it within one script
budget; past it, a calculation is left as it is, like one that won't decode.
- Field detection reads a page's content within one page's allowance
(`DecodeBudget::page`), as the content walks do, however often /Contents
lists a stream.
- The PDF/A declaration is read from at most 8 MiB of XMP, as preflight's
Standards panel reads it, and only once /NeedAppearances isn't already set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M6: the forms README says which metadata declares PDF/A
declares_pdfa reads pdfaid:part from XMP metadata that decodes within
8 MiB, as preflight's Standards panel reads it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M6: a test pins that metadata without pdfaid:part declares no PDF/A
cargo-mutants found `==` turned to `!=` in declares_pdfa's search unpinned:
no case had metadata within the cap that declares nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M6: field detection reads operators one at a time; the PDF/A check reads XMP as preflight does
From the Resources review of 66415d737:
- page_shapes collected every parsed operator, about 80 bytes for each
decoded byte of dense content, so a page within its 256 MiB allowance
could take some 20 GB. It now reads them one at a time
(pdfcraft_content::ops) and drops the pieces once joined.
- declares_pdfa decoded through a DecodeBudget, which counts every filter
stage against the 8 MiB cap, so XMP stored through a chain of filters
stopped declaring PDF/A while preflight's Standards panel, which caps
each stage, still read it. It reads the metadata as preflight does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M6: say what declares PDF/A to forms, and why its metadata read stays direct
From the Legal-forms review of c00e3d2fb: the README and doc comments said
forms reads the declaration "as preflight's Standards panel reads it". The
two share the 8 MiB cap, applied to each filter stage, but forms matches the
bytes pdfaid:part and preflight the identifier by its namespace, as on main.
The sweep's direct-/DecodeParms hit is answered at the site: preflight and
organize read metadata the same way, and the readers move together.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* forms: rename a shared calculation once; keep unread defaults on Clear form; cap q in detection
- A Calculate action that several fields share by reference is read and
rewritten once, in place, so every calculation reads the new name and
the script is decoded once, not skipped past the budget.
- Clear form leaves a field alone when its /DV is a stream the value
budget didn't reach, instead of emptying it.
- Field detection keeps at most 256 saved graphics states; deeper q are
counted so each Q still restores its own.
- The dense-content test checks that the budget is spent; exhausted() was
true from the start with no form visits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* forms: restore the matrix past the save cap; reset a value that wasn't read
- Field detection keeps the matrix of the first save past MAX_SAVES and
restores it when the last of them closes. A shape painted after a Q
that would have undone a cm inside those saves is dropped, as the
matrix there isn't known, instead of being reported scaled.
- Clear form skips a field only when its value is known: one whose /V
is a stream the value budget didn't reach is reset, not kept as if it
equalled an empty default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* forms: keep detection's saved states in a Saves type
shapes_within went past clippy's cognitive complexity limit (32/30) with
the save-cap tracking inline. The same logic now lives in Saves (save,
restore, moved, unknown).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* M11: JBIG2 images share a symbol dictionary, up to 16 at a time
With Monochrome Images set to JBIG2, Optimize stored each image's
letters in a symbol dictionary of its own, so a document of N pages
carried its font's shapes N times. Now up to 16 images the run stores
as JBIG2 share one dictionary in a /JBIG2Globals stream, holding the
marks found in more than one of them; each image's text regions refer
to it first, then to a dictionary of its own for the marks only it
repeats. An image is stored that way when it comes out smaller, and a
group only when what its images save is more than the globals take.
- filters: encode_jbig2_shared codes a group: the globals are a symbol
dictionary segment numbered 0 and associated with no page (7.2.6);
each page numbers its segments from 1, as a segment refers only to
lower numbers (7.2.5), and the shared symbols' IDs come first, as the
dictionaries are referred to in that order (Table 31, SBSYMS). At
most 16,383 shared marks, those in the most images, so a page keeps
room for its own under the decoder's 32,767.
- optimize: the run holds the 1-bit samples of the images it stores as
JBIG2 (16 at most, 64 MiB at most) and stores them again sharing,
weighing each with the longest reference the globals could have.
Eight pages of 10 pt text rendered at 300 dpi take 41% less in
Helvetica and 42% in Times than coded alone, 10% in Courier, and 46%
in Helvetica at 600 dpi; groups of 2, 4, 16 and 32 save 19%, 33%, 45%
and 47%. Redacting one image stores the others of its group again
without the dictionary, so 16 keeps that to 15 images.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: test what the mutants found untested in shared JBIG2 dictionaries
- filters: the group goes through encode_jbig2_shared, which refuses it
for an image the encoder refuses; two marks in common are enough to
share; past the 65,536 distinct marks a group holds, a mark first seen
later isn't shared while those held count every image.
- optimize: optimizing again keeps both the shared pages and the pages
coded alone as they are; under Retain, averaged JBIG2 pages share
nothing; a last group of two shares.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: only images redaction can store again share a JBIG2 dictionary
Redacting one image of a group stores the others again without the
dictionary, which can hold the shapes of marks under the area, and
refuses the redaction for one it can't: it takes image masks and
images of one component as its own reading counts them (no DeviceN,
the family named directly), and at most 64 Mi pixels. Optimize took
DeviceN of one colourant and up to 80 million pixels, so such an image
in a group made any redaction of the others fail, where before it was
coded alone and the same redaction worked (the Consumers review).
Only an image mask or a DeviceGray image named directly, of at most
2^26 pixels, now joins a group; others are coded alone. An engine test
optimizes three pages, two DeviceGray and one DeviceN, and redacts the
first: the DeviceN page stays alone, the second is stored again, and
no image names the dictionary after a save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: optimize and redaction share one limit on the images redaction stores again
The second Consumers review: Optimize's limit on images that share a
JBIG2 dictionary copied redaction's private 64 Mi, with nothing tying
the two, so a lower one in redaction would bring back refused
redactions with no test going red. Both now read
pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS. The engine test adds an image
mask, the other kind that shares, so a redaction that stopped storing
either kind again fails it. doc_optimize's description, the README,
the parity note and ROADMAP say which images share (named directly).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: a JBIG2 group is coded before its samples would pass the limit
CodeRabbit on #5910: hold added an image to the group and then checked
SHARED_BYTES, so a group could pass the 64 MiB it is documented to stay
within by its last image, which can itself be up to 64 MiB (an image
that shares has at most 2^26 pixels, and as its rows are whole bytes,
at most that many bytes; a 1-pixel-wide one takes them all). hold_in
codes the group first when an image would take it past the limit, and
after when it has 16 images; with room for 10 bytes, images of 4 go two
to a group, where the old order made one of 12. CodeRabbit's follow-up
too: the 18-image test decodes every image and compares it with its
samples, across the boundary between the groups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* optimize: count the globals' object framing; filters: test the shared coder's size limit
- A JBIG2 group shares a dictionary only when it saves more than the
globals' dictionary and data plus NEW_OBJECT (96 bytes: the object's
framing, its xref entry, and an encrypted file's IV and padding), so a
group that saves less than that no longer makes the file larger.
- encode_jbig2_shared is Unsupported for an image taller than the
decoder takes and codes one at the bound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* filters: drop the unused rows binding in encode_shared
page() no longer takes the rows since #5511.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* filters: the shared coder's at-bound test says what it returns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* M1: a crypt filter selected by reference is used
A stream can name its own crypt filter, and every part of that can be indirect. cos read only the
direct forms, and took anything else for no name, which is /Identity. So a stream selecting a filter
by reference was saved in the clear in a file meant to be encrypted, and in a file from elsewhere
its ciphertext was read as data. The /Encrypt dictionary's /StmF, /StrF, /EFF, /CFM, /Filter,
/EncryptMetadata and a filter's /Length had the same fault, and /StmF by reference left every
stream undecrypted. A stream's /Type and a signature dictionary's are read through references too.
transform now reads these from the document, so a full save encrypts before it renumbers: the
references in what it reads have to be the document's. Dropping a crypt filter that new protection
doesn't define comes before the walk, so what it copies out of a reference is encrypted too.
Loading can re-enter here, and LoadGuard ends a cycle, which reads as absent. A chain longer than
any decoder takes names no filter: its entries would be followed for every stream sharing it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M2: a stream with a crypt filter of its own is drawn
The renderer decrypted every stream with the document's filter and then failed on /Crypt as a filter
it couldn't decode, so a stream naming its own filter read as nothing, however it was spelled, and a
page whose content was such a stream stayed blank. It now uses the filter the stream names, /Identity
without one, and /Crypt is nothing further to decode (hayro-syntax patch 30).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: tests for a crypt filter's place among the others
A lone parameter dictionary is the first filter's, so /Crypt behind another filter has no name of
its own, and an array too short for /Crypt's entry stays as it is when the filter is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: encryption reads every legal form, and a full save keeps them
From a review of the crypt filter fix.
The renderer read a stream's own filter from the form /DecodeParms had to match the form of /Filter,
so [/Crypt] with a lone dictionary, an everyday spelling, read as /Identity and the page stayed
blank. Parameters are now taken in either form, which also gives [/FlateDecode] its predictor.
It also refused a V4 file without /StmF or /StrF, which are optional, and used a /CF entry named
/Identity, which is never one. A chain past what cos decodes names no filter there, so it names none
here either (hayro-syntax patch 30).
A full save lost what a direct /Encrypt refers to, and kept its references under the old numbers, so
nothing in the file read afterwards. /O or /U given by reference were decrypted and encrypted again
under a new number, so the file it wrote no longer opened. What /Encrypt and /ID refer to now stays
as stored. /ID by reference, which has to be direct, is read, and written direct.
A signature dictionary is known by a /ByteRange given by reference, and a null or dangling /Type or
/Subtype is an absent one. References to references are followed in Document::resolve, so /Name by
way of two no longer saves a stream in the clear. New protection drops every /Crypt in a chain.
An object stream's /DecodeParms can be given by reference.
Reading what decides how an object is treated can load more objects, each doing the same, which a
file could make a tree that doubles with every level once the cache lets go of it. What is loaded
for one of these no longer follows its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: what a read follows is bounded by a budget, and nothing shared is copied per stream
From a review of the fixes before this, all in them.
A flag that kept what is loaded for a selector from following its own stopped the fan-out, but a
legal file lost object stream members to it: read first through a selector, an object stream with
parameters by reference was kept undecoded, and a full save wrote nulls without saying so. A budget
replaces it: one read or write of an object, with all it loads on the way, follows up to 32
references, which is more than any object needs when all of it is by reference. Past that they
read as absent, so a file's ladder of selectors still stops, and nothing a real file holds is lost.
Finding out whether a dictionary is a signature's loaded objects for every dictionary of an object,
and a load that fails takes a scan of the file, so one read could hang. Only a dictionary with a
direct string /Contents is asked now, and the budget bounds those.
/ID written direct copied every entry, so a 1 MB file naming a large string thousands of times ran
out of memory: it is at most two strings now. Dropping a crypt filter copied a shared /DecodeParms or
/Filter into every stream, 1 GB from a 1 MB file: of what's shared only filter names and the
parameters decoders read are copied now, through Document::with_direct_parms.
The renderer skips /Crypt in a chain instead of copying the data for each entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: only an object itself follows references, and a failed scan is remembered
From a review of the budget.
Following a reference while walking deep inside an object added that depth again for each object
loaded, which overflowed a 2 MiB stack on a file under 10 KB and took the process with it, past any
catch_unwind. References are now followed for the object itself and never for what is nested in it.
A stream can't be nested, and a signature dictionary nested in another object reads as it says
directly, as it did before this PR. walk is back to following nothing.
A reference that fails to load scans the file, and nothing remembered that, so a 1 MB file took
minutes to save. What a scan didn't find is kept for the document: the file doesn't change.
Filter names copied out of a shared chain are bounded, since a name can be any length. /ID keeps each
entry in its place, or the key made from the first entry changes. direct_id's comment was on another
function.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: tests with no deadline a busy machine can reach
Wide margins on the two that time what they guard. A test of dictionaries nested in an object goes:
nothing nested follows a reference now, which the small-stack test covers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: a crypt filter past what a read may follow is the document's own
Past the budget a reference reads as absent, and a crypt filter with no /Name is /Identity, so a stream
that selected one by reference had its ciphertext read as data. What can't be told takes the document's
filter, as a /Filter that can't be read does.
Found by CodeRabbit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: a read refused, not a budget full, is what leaves a crypt filter unknown
My last commit took a read that used up all it may follow for one that was refused, so an /Identity
stream read at exactly 32 follows was decrypted to garbage. Refused reads are counted now, and only
one on the way to this stream's /Name says anything about it.
Found by CodeRabbit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: a save keeps the handler, the key and the permissions
Under every algorithm and by either kind of save, `/Encrypt`, `/ID`, the file key and the permissions
come back as they were. No test held that. `/ID` by reference is held through an incremental save
too, which leaves it as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* cos: encrypt an edited or added object whatever /Encrypt names
stored_as_is now also requires the object to be unedited, so an edit of
an object /Encrypt or /ID refers to, or a new object given a number they
name, is encrypted on both saves. encryption_members skips numbers with
no object behind them and streams, which those dictionaries never hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The merge left a | after patch (13)'s test, which made a sixth cell and
hid (14) and (16) from the rendered table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node_of read only the keys it knew and ignored the rest, so a misspelt
key such as "colour" was dropped and the bookmark imported without its
colour and without an error. A node may now carry only the eight keys
bookmark_export writes (title, page, view, color, bold, italic, open,
children); any other key refuses the whole import, naming the node's
path and the key, before anything is written. The tool description
says so.
Test: a_misspelt_bookmark_key_is_refused_not_dropped puts "colour" on
a nested node and checks the import is refused and the outline left
empty; it fails with the check reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
vendor/README.md: patch (13), from #5999, goes into this branch's
hayro-jbig2 row ahead of (14) and (16).
The page-area test now fills the page's words with 1s, padding included:
since patch (13) new_with clears the padding, so the test no longer
exercised the page-width half of the mask.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fix from #6087. tess_3d charged one element per triangle, but each
triangle also holds its normal-slot corners and one corner triple per
texture set any face of the mesh uses, up to eight. Faces reusing one
index range could so allocate well past the 128 MiB the element ceiling
documents. Each face's triangles are now charged in 8-byte elements for
everything they hold (corners, normal slots, texture triples, style)
before the face is triangulated.
Tests, each shown failing with the fix reverted:
reused_textured_faces_are_charged_for_every_corner_array,
faces_reusing_one_index_range_are_charged_for_each_triangle_they_make.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* M2: a JBIG2 refinement reads 0 past its reference's width
hayro-jbig2 keeps each row of a bitmap in 32-bit words and filled a new
bitmap whole, so with a default pixel of 1 the bits past its width in
each row's last word were 1s too. A refinement reads its reference a word
at a time, those bits included, where ISO/IEC 14492 6.3.5.2 says a pixel
outside the reference is 0. A refinement of the page, or of an
intermediate text or halftone region, whose default pixel is 1 and whose
width isn't a multiple of 32 decoded to other pixels than were coded.
Vendored patch 13: Bitmap::clear_padding clears those bits when
new_with or reinitialize fills a bitmap with 1s, as cutting symbols and
patterns out of a collective bitmap already did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M2: the doc on a JBIG2 bitmap's words says their padding is 0
Three places in hayro-jbig2 now clear the bits past a row's width, and
the field's doc said only that rows are padded. It now says the padding
is 0 and why: generic and refinement decoding read the rows above their
own pixels a word at a time, as a refinement reads its reference, so
whatever next writes whole words keeps it 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M11: JBIG2 encodes only what its decoder takes: Optimize no longer loses large images
encode_jbig2 took rows of up to 2^20 samples and any number of them, while hayro-jbig2, which
draws and reads these streams, takes 65,535 on a side and 2^28 samples. The optimizer doesn't
read back what it stores, so with Monochrome Images on JBIG2, any 1-bit image with a side of
65,536 or more was replaced by a stream that draws nothing: 65,536 × 16 went from 37,875 dark
pixels to none, and was reported as recompressed.
The encoder now refuses what the decoder can't read, so such an image stays as it is. Symbol
coding's limit of 2^24 rows can't be reached any more, so it goes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the JBIG2 boundary test checks a pixel
It counted rows, which a page decoded blank would pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the encoder's bound on samples is MAX_JBIG2_PIXELS, the decoder's
main brought decode_jbig2 and its public MAX_JBIG2_PIXELS into this crate,
2^28 like this branch's private MAX_JBIG2_SAMPLES and for the same reason.
One constant now bounds both, so the encoder can't drift past what the
decoder reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: an image too large for JBIG2 isn't stored as JBIG2, as the notes say
The parity note said such an image stays as it is. A 1-bit one does unless
it is averaged, but one packed from more bits may become 1-bit Flate. The
note now says it isn't stored as JBIG2 and cites
an_image_too_large_for_the_jbig2_decoder_stays_as_it_is, and the optimizer
README lists the bound under Not yet. MAX_JBIG2_PIXELS is the most the
decoder takes, not about that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the optimizer's own size limit, and tests for what JBIG2 can't take
The optimizer leaves images over 80 million pixels alone whatever the
settings, and nothing said so; the README and parity note say it now, and
that JBIG2's 2^28 in all is past it, so from Optimize the side bound is the
one that applies. The note says what the image step does: it leaves a
1-bit image past the bound as it is unless it is averaged, and may store
one packed from more bits as 1-bit Flate.
what_is_too_large_for_jbig2_is_stored_another_way tests those two cases:
eight-bit grey of black and white at 65,536 wide becomes 1-bit Flate, and
a 1-bit image at 600 ppi averaged to 70,000 wide does too. At 65,535 and
35,000 both become JBIG2.
The symbol cap's comment says why hayro-jbig2 refuses 32,768 symbols: it
caps new and exported symbols together at 65,535, and a plan exports every
symbol it codes. Measured: 32,767 decode, 32,768, 40,000 and 65,535 are
refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the 80-million-pixel limit is the image steps', tested on both sides
The README and parity note said images over 80 million pixels are left
alone whatever the settings, but Clean Up still Flates an unfiltered or
LZW one. They now say the image steps leave them alone, and the note cites
group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit for it.
(The last commit said the note also gives 2^28 as past that limit; only
the README does.)
what_is_too_large_for_jbig2_is_stored_another_way covered the width
bound only: it now runs tall images too, packed and averaged, and checks
the averaged ones are one bit and half the size both ways. The older test
uses the same bars() fixture, and its comment says such a stream drew
nothing in PdfCraft, not that the picture was lost: Poppler and
Ghostscript draw it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the JBIG2 stored at the decoder's bound reads back, and grey keeps the pixel limit
- The optimize test at 65,535 on a side decoded nothing it stored: it
checked the filter only, and the coding stored there is symbols, not
the generic coding the filters test reads back. It now decodes the
stored stream and compares it with the bars.
- The README's 80-million-pixel sentence covers the colour and grey
steps too, and only the 1-bit step had a test. A grey image of
10,000 × 8,000 at 600 ppi is resampled; a row more stays as it is.
- The filters README names decode_jbig2_within, with its work, as main
renamed decode_jbig2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the filters README names #5569's other renames too
The README named decode_jbig2_within but still decode_ccitt, which
#5569 made private behind decode_ccitt_within, and gave
Document::decoded_image as both decoders' caller, which is test-only
now: ImageBudget::decode calls them, as lib.rs's doc says. The ccitt.rs
test line keeps decode_ccitt, which those tests call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A cut copies the subtree, then deletes it. When the copy is truncated
(bookmarks nested past the listing depth), paste cannot bring back what was
left out, so the cut now keeps the copy, deletes nothing and says why.
Test: cutting_a_subtree_too_deep_to_copy_keeps_it (40-level synthetic
outline); fails when the truncated copy is allowed to delete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
The Bookmarks panel's item menu gets Cut, Copy and Paste, and Bookmark
options gets Paste bookmarks (to the end of the outline, which is how you
paste into a document without one). The clipboard lives in the app
(`PdfCraftApp::bookmark_clipboard`), like the Organize Pages clipboard, so
it works across tabs and on the web build.
- Copy reads the subtree with `outline_tree`; a copy cut at the listing
depth says so.
- Cut copies first and deletes only if the copy succeeded: one undo step.
- Paste is one `ImportBookmarks` edit after the bookmark it was asked on,
at its level. A bookmark whose page the document lacks keeps its place
without a destination, and a notice counts them (plural forms through
`trn`).
Parity: new `organize.bookmark-clipboard` entry with what is missing
(shortcuts, system clipboard, multi-select, page-ordered insertion).
Test: cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents.
Reverting copy, cut, paste, the paste's parent, the one-step undo or the
notice each fails it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
- organize: a /FitR destination missing one of its four numbers is copied
with its page and no view, instead of failing the whole copy (the
rectangle can't be kept, and import refuses a partial one).
- organize: `visit` keeps the visited node's path in step with its stack
instead of rebuilding it per node (O(depth) per node before).
- automation: an empty title is accepted when present, so a bookmark a
file left untitled exports and imports back; a missing title is still
refused.
- automation: "no bookmark at" names the path 1-based, as it was given.
- automation: `bookmark_copy` and `bookmark_import` report `truncated`;
an exported file carries `"truncated": true` when the export left
bookmarks out, and importing it says so.
- automation: `bookmark_export` is marked destructive (it overwrites
`out`) and idempotent for MCP.
Tests: a_fit_r_missing_a_value_copies_with_its_page_and_no_view (organize),
odd_bookmarks_round_trip_and_a_cut_tree_says_so (automation). Each fix
reverted alone fails one of them; the visit change is behaviour-neutral
and covered by the existing outline tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
bookmark_export returns an outline, or one bookmark's subtree, as JSON:
titles, nesting, open state, colour, bold/italic and destinations as page
plus view in PDF user space (ISO 32000-2 12.3.2.2), optionally written to
a file. bookmark_import takes it back, inline or from that file, under any
bookmark at any position or in place of the outline, as one undoable edit;
bookmark_copy does the same from another open document by page number.
A bookmark whose page the target lacks keeps its place without a
destination and is reported in `unmapped`. Every node is checked before
anything is written (title, page, colour, view type and arity, 33 levels,
100,000 bookmarks), and the error names the node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review fixes from #6087:
- Picture and uncompressed blocks are charged to the parse ceiling before
they are copied. File-structure headers may overlap, so each structure's
copy counts. The ceiling test now includes the 3-byte picture.
- Schema expressions use checked multiply, add and subtract; an overflow is
Malformed rather than a wrapped loop count.
- Each face's triangles are counted from its entity sizes and charged before
it is triangulated.
- Matrix::mirrors divides each row by its largest entry before taking the
determinants, so a mirror whose determinant underflows still reverses the
winding.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A negative w (uniform or projective) turns mapped triangles inside out
though the 3x3 block does not mirror; the flip and the normal sign now
follow the 4x4 determinant, falling back to the 3x3 block when the last
row is zero.
The tree walk read each structure's globals schema with its own full
allowance, then again for the globals. It now reads it once, charged to
the walk, and continues from there for the globals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9