Commit Graph
3728 Commits
Author SHA1 Message Date
FredApps 8f8842e3ab Merge pull request #5396 from KenM76/fix/ocr-edit-under-scan
fix(edit): an edited OCR phrase under its scan image is drawn on top (#3973)
2026-11-04 19:42:25 +01:00
FredApps 60894af44d Merge pull request #6125 from KenM76/feat/bookmark-clipboard
feat(ui): cut, copy and paste bookmark subtrees (#3994)
2026-11-04 19:33:28 +01:00
FredApps 9070431ed3 Merge pull request #5250 from KenM76/fix/cleared-max-len-stays-cleared
fix(forms): clearing a field's length limit leaves it with none, not its parent field's
2026-11-04 19:08:06 +01:00
FredApps 3788af11d3 Merge pull request #5808 from KenM76/feat/3d-embed
M12: add a 3D model (U3D or PRC) to a page with a poster (#1297)
2026-11-04 18:53:39 +01:00
FredApps 83cbf8f89f Merge pull request #5476 from KenM76/fix/xfa-check-button-width
M12: an XFA check box without a width is as wide as its box and caption
2026-11-04 18:53:31 +01:00
FredApps 5bf6b63910 Merge pull request #5487 from KenM76/fix/file-dialog-extension-case
fix(ui): list X.PDF in file pickers on Linux and the BSDs (#5481)
2026-11-04 18:53:24 +01:00
FredApps 8d7eb74ff7 Merge remote-tracking branch 'upstream/main' into pk-6125m 2026-11-04 18:50:24 +01:00
FredApps 7d17aea92c Merge pull request #5786 from KenM76/feat/prc-reader
M12: PRC reader crate (`pdfcraft-prc`) (#1297)
2026-11-04 18:50:09 +01:00
FredApps 899f3806b1 Merge pull request #5432 from KenM76/perf/png-predictor-bytes
perf(render): undo PNG predictors a byte row at a time (#5430)
2026-11-04 18:44:45 +01:00
FredApps 61b80cb646 Merge pull request #6115 from KenM76/feat/outline-io
feat(organize): export, import and copy bookmark trees (#6018)
2026-11-04 18:44:33 +01:00
KenM76andClaude Opus 5.5 69f4d0a8fe Merge origin/main into perf/png-predictor-bytes
Resolves vendor/README.md: main's hayro-syntax row, with this branch's
patch (35) and its tests appended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 08:33:14 -05:00
8717a085ff M2: a JBIG2 refinement reads a reference right of it from its first column (#6048)
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>
2026-11-04 13:51:47 +01:00
c0cd40f2e1 M6: forms decodes field values, renamed calculations and detected pages within shared budgets (#6132)
* 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>
2026-11-04 13:51:40 +01:00
d579f26de3 M11: JBIG2 images share a symbol dictionary, up to 16 at a time (optimize.mono-compression) (#5910)
* 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>
2026-11-04 13:34:08 +01:00
FredApps e989e48e63 Merge pull request #6058 from Piohrun/fix/jbig2-page-refinement-area
fix(render): a JBIG2 refinement of the page refines only its area (hayro-jbig2 patch 16)
2026-11-04 13:33:52 +01:00
9203e4a6be M1: encryption reads every legal form: crypt filters by reference, a stream's own filter on screen, and full saves that keep /Encrypt (#5548)
* 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>
2026-11-04 13:17:29 +01:00
KenM76andClaude Opus 5.5 6cb3dbe8cc Merge feat/outline-io and origin/main into feat/bookmark-clipboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 07:11:34 -05:00
KenM76andClaude Opus 5.5 e79b559455 automation: bookmark_import refuses a node key it does not know
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
2026-11-04 07:10:55 -05:00
FredAppsandClaude Opus 5.5 160ee3d421 Merge main into fix/jbig2-page-refinement-area
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>
2026-11-04 12:50:48 +01:00
KenM76andClaude Opus 5.5 bca0211adc M12: PRC reader: charge each triangle's corner arrays, not only its corners
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
2026-11-04 06:50:26 -05:00
PiohrunandClaude Opus 5.5 e38b9d182e fix(render): JBIG2 refinement reads 0 past its reference's width (hayro-jbig2 patch 13) (#5999)
* 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>
2026-11-04 12:49:49 +01:00
PiohrunandClaude Opus 5.5 bc7c60bb41 M11: JBIG2 encodes only what its decoder takes: Optimize no longer stores large images PdfCraft can't draw (#5511)
* 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>
2026-11-04 12:49:11 +01:00
FredApps 3683150ee0 Merge pull request #5803 from alehel/refactor/page-press-dispatch
refactor(ui): tell page modules whether a press is theirs (router PR 4a)
2026-11-04 11:56:35 +01:00
KenM76andClaude Opus 5.5 bfa6db7bd3 M4: a cut too deep to copy leaves the bookmarks in place (#3994)
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
2026-11-04 05:53:41 -05:00
KenM76andClaude Opus 5.5 d6b1b000b5 Merge remote-tracking branch 'origin/main' into feat/prc-reader
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 05:17:16 -05:00
KenM76andClaude Opus 5.5 c16c6b5716 M4: cut, copy and paste bookmark subtrees (#3994)
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
2026-11-04 05:04:49 -05:00
KenM76 5bbcb7a218 Merge remote-tracking branch 'origin/main' into feat/outline-io 2026-11-04 04:40:33 -05:00
KenM76andClaude Opus 5.5 43939cc13f M4: bookmark tree round-trips what real files hold; review fixes (#6018)
- 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
2026-11-04 04:39:20 -05:00
KenM76andClaude Opus 5.5 801976a574 feat(organize): export, import and copy bookmark trees (#6018)
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
2026-11-04 03:37:55 -05:00
FredApps 69fda9959e Merge storytold/main into fix/scroll-regression-1230 2026-11-04 08:56:42 +01:00
FredApps d21abe9881 test(ui): keep the eviction-order test over the window-sized allowance 2026-11-04 07:42:59 +01:00
FredApps 933a92fad0 Merge storytold/main into fix/continuous-scroll-prefetch 2026-11-04 07:42:58 +01:00
FredApps 1f8cb45353 Merge pull request #6065 from dotCooCoo/fix/window-sized-page-budget
fix(ui): size the whole-page raster allowance to the pages on screen
2026-11-04 07:42:41 +01:00
FredApps bde72b6f26 test(ui): pin the whole-page allowance ceiling per target 2026-11-04 07:03:52 +01:00
KenM76andClaude Opus 5.5 b4ba4edc7f M12: PRC reader: charge copied blocks, refuse overflowing schema expressions
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
2026-11-03 23:58:08 -05:00
KenM76andClaude Opus 5.5 d0286a745a M12: PRC reader: winding by the whole determinant; walk charges each schema once
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
2026-11-03 22:56:09 -05:00
KenM76andClaude Opus 5.5 b1a6128dbd M12: review fixes from the renderer PR's review of the reader
- Huffman leaf tables where one code begins another are malformed; the
  shorter leaf would end the longer code early and misalign the payload.
- A model-file end past the input is malformed rather than clipped.
- Each schema entry costs one element, so empty programs are not free.
- tessellations() carries the globals schema's remaining allowance into
  the tessellation reader: one stream, one budget.
- A face behaviour wider than 16 bits is malformed, not zeroed.
- Coordinate arrays that are not whole points are malformed; a partial
  last normal drops the stored normals, as an unusable normal slot does.
- Triangles a TESS_3D's faces emit are charged to the element budget as
  they are made, since faces may reuse one index range.
- TriangleMesh::transformed drops stored normals under a projective
  matrix (last row not [0, 0, 0, w]).
- Placement::tessellation's doc says placements() does not check it
  against the tessellation section.

Each fix has a test, seen failing with the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 21:36:07 -05:00
KenM76andClaude Opus 5.5 43b95919bb Merge origin/main (9ce6d89f) into feat/prc-reader
ATTRIBUTION.md regenerated with `cargo xtask assets --write` (255 on main + this branch's one asset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 19:26:39 -05:00
9ce6d89f46 i18n: interface translations from craft-i18n (#6055)
* build(i18n): embed the craft-i18n catalogs from a pinned checkout

- `craft-i18n.rev` pins storytold/craft-i18n at e45ddf7 (the PdfCraft cutover sync of
  pdfcraft@ecdd1e6); `.gitattributes` keeps it LF.
- The `craft-i18n` crate (craft-libs 787a7a0, PdfCraft's existing pin) is a direct dependency
  of the UI crate; it was already in the tree through craft-ui.
- `crates/ui-egui/build.rs` writes `CRAFT_I18N` from `CRAFT_I18N_DIR`: one (locale, common.tsv,
  pdfcraft.tsv) entry per locale with a `pdfcraft.tsv`, paths written with `{:?}` and never
  canonicalized. Unset it is empty (English only); `CRAFT_I18N_REQUIRED=1` makes a missing or
  invalid checkout an error.
- Both flatpak manifests name the craft-i18n commit and `CRAFT_I18N_DIR`; a test keeps both
  commits equal to the trimmed pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(i18n): look strings up through craft-i18n behind a thin Lang wrapper

`i18n.rs` keeps PdfCraft's API (tr, tr_ctx, tr_id, trn, key_name, fmt,
Lang, LANGUAGES, detection) and hands lookups to the craft-i18n crate:
`get` for key names, `trn_plain` for plurals (no isolates, no digit
grouping), PdfCraft's own `fmt` (no FSI isolates). `@id` rows and the
"Undo {action}" template are read only where PdfCraft had them, so menus
and history labels read exactly as before.

Detection is unchanged (LC_ALL/LC_MESSAGES/LANG, macOS defaults,
sys_locale); the crate's match_tag would drop nb for no/nn, so PdfCraft's
own tag mapping stays, with one parity test per path.

Completeness tests become reports; tests that need catalogs skip with a
note when the build has none. Font coverage tests read the embedded
catalogs (shared and PdfCraft rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(i18n): remove the in-repo catalogs; craft-i18n holds them

The 34 `crates/ui-egui/src/i18n/*.tsv` catalogs were migrated into
craft-i18n at pdfcraft@ecdd1e6 and are now embedded from the pinned
checkout. ATTRIBUTION.toml drops their 34 entries for one craft-i18n
`[[build_input]]` (craft-i18n's ATTRIBUTION.md credits the translators);
AGENTS.md and docs/localization.md describe the new system and how to
add a language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(i18n): skip catalog tests without craft-i18n; drop the catalog hash exemption

Tests that switch to a non-English language skip with a note when the
build embeds no catalogs (CRAFT_I18N_DIR unset), so the English-only
build tests green. `skip_without_catalogs` moves to the integration
test support module.

With no catalogs left in the repository, `cargo xtask assets` no longer
exempts `crates/ui-egui/src/i18n/*.tsv` from hashing: every `[[asset]]`
carries a sha256 again, and a `.tsv` added back would need one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(i18n): watch the locales directory before reading it

A CRAFT_I18N_DIR that doesn't exist yet now reruns the build script once it appears (review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(i18n): pin craft-i18n dfbe955, the merge of storytold/craft-i18n#129

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): skip an @id row that changes the label's placeholders or ellipsis

craft-i18n checks placeholders and the trailing ellipsis on every row whose
source is English text, but not on @id rows, whose source is the command id.
The in-repo catalog test used to fail on such a row; since the cutover it only
prints it, while tr_id and menu_label still showed it. Both now use PdfCraft's
own @id row only when it keeps the label's shape (same_shape, the rule
craft-i18n applies elsewhere), and otherwise show the label's translation, as
bundled_catalogs_are_consistent says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): reject an @id row with an open brace; list unused @id rows in the gaps report

same_shape skipped a `{` with no `}`, so an @id row such as "Öffnen {name…"
for "Open…" was still shown. It now counts as a changed shape.

bundled_catalogs_are_consistent printed the @id rows PdfCraft doesn't use, but
CI captures test output, so nobody saw them. translation_gaps_report, which
`cargo xtask i18n-report` runs with --nocapture, now lists them
(unused_id_rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): reject an @id row with a `}` that closes no placeholder

same_shape counted only the `}` that close a `{`, so "Öffnen name}…" for
"Open…" or "{name}} öffnen" for "Open {name}" still passed. A row whose `}`
count differs from its placeholders now counts as a changed shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): no braces in the assert messages of the brace test

assert! reads its message as a format string, so "a `}` without its `{`"
did not compile.

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>
2026-11-04 00:47:17 +01:00
Grzegorz MartensandClaude Opus 5.5 07f4403884 M2: a JBIG2 refinement of the page refines only its area
A refinement of the page refines "the current contents of the page
buffer, restricted to the area of the page buffer specified by this
segment's region segment information field" (ISO/IEC 14492 7.4.7.4), at
an offset of 0 (Table 35), and reads 0 outside that area (6.3.5.2).
hayro-jbig2 read the whole page at the refinement's location, the
pixels around the area too, so a refinement of the page decoded to
other pixels than were coded, and one wider or taller than the page was
refused.

Vendored patch 16: Bitmap::area copies the page's area, 0 off the page
and past its width whatever its rows' padding holds, and both handlers
refine that. Every refinement region now reads its reference at offset
0, so the per-page offset #6016 kept goes.

And RefinementContextGatherer::start_row loads the reference's words a
column left of where a row starts in it, as the reload would, not from
column 0. With the reference offset 32 or more left of a refinement (a
refined text instance from a narrower bitmap or an RDX below -31, a
refined symbol from such an RDX), each row's first context read 0 for
the reference column it starts at, and from 33 left for the one before
it too. That half was left out of #6048: alone
it read the page column left of a page refinement's area, which the
area now keeps at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-03 23:16:10 +01:00
dotCooCoo a262171999 fix(ui): continuous view renders the pages ahead of the scroll
In continuous view the only demand off screen was the pages near the
window. A fast wheel spin crosses them faster than a heavy page renders,
so pages came into view blank.

The view now keeps the direction it last moved and also asks for the
next two rows that way (four pages in two-up). A zoom or a layout change
clears the direction. Scrolling the other way drops those pages from the
queue, which cancels their renders. Pages on screen stay at the head of
the queue in their usual order, and the byte and count limits bound the
prefetch. The web build renders on the UI thread and does not prefetch.

A raster that arrives over the allowance now evicts the cached pages the
frame does not ask for first, then those behind the scroll, so it no
longer evicts a prefetched page that the next frame asks for again.

A prefetched page that is tiled at the current zoom keeps a cached
whole-page raster sharper than its backdrop, as a tiled page on screen
does. This also applies to the prefetch of single-page and two-page view.
2026-11-03 13:53:52 -08:00
dotCooCoo 1a2104d428 fix(ui): size the whole-page raster allowance to the pages on screen
Whole-page rasters shared a fixed 128 MiB allowance. In a 3440 x 1440
window a Letter page at fit width is a 2,980 x 3,850 raster (43.8 MiB),
so two pages on screen left no room for another, and scrolling back
over five pages rendered all five again. Zooming in on a smaller window
has the same result.

The allowance now holds eight rasters the size of the largest whole
page on screen, between 128 MiB and 384 MiB (256 MiB in the web build).
The size comes from the page's request at the current scale, or from
the raster held for it while a zoom hold leaves the request out. A
tiled page is on screen as a small backdrop and does not raise the
allowance. Queue admission, keeping pages already rendered, and
texture admission use the same allowance. Tiles keep their own
128 MiB allowance.
2026-11-03 13:36:13 -08:00
dotCooCoo 292f9c75ff Scroll: smooth wheel bursts and preserve precise movement 2026-11-03 12:12:11 -08:00
Dylan M. Taylor 801fd3aa67 fix(engine): decode signature preview images within a small work allowance (#5981)
* fix(engine): decode signature preview images within a small work allowance

* style: rustfmt
2026-11-03 15:10:02 -05:00
Dylan M. Taylor 76260bb6a5 fix(xfa): scripts come only from the XDP's own packets and quote the date pattern (#5987)
The template (and the saved form state) was found anywhere under the XDP root, so a
template-shaped element inside imported datasets could supply the form and its event
scripts. A date field's pattern went into AFDate_FormatEx/KeystrokeEx string literals
unescaped.
2026-11-03 14:33:41 -05:00
Grzegorz MartensandClaude Opus 5.5 b9e068aa36 M2: a JBIG2 refinement of a region refines it where it is
"GRREFERENCEDX 0, GRREFERENCEDY 0" (ISO/IEC 14492 Table 35): a refinement
region segment that refers to another region refines that region where
it is. hayro-jbig2 offset the reference by the difference of the two
locations, which 7.4.7.5 says must be equal; where a stream has them
differ, the refinement decoded against a shifted reference, or, the
region past i32::MAX, against none. It now decodes as coded, and only a
refinement of the page takes an offset, from its own location.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-03 19:32:29 +01:00
Grzegorz MartensandClaude Opus 5.5 01d3479f26 M2: a JBIG2 region located past i32::MAX is off the page
A region's location is unsigned (ISO/IEC 14492 5.4.4), and hayro-jbig2
took it for a signed one. A region at 0xFFFFFFFC was drawn back onto the
page four pixels left of it, and a refinement anywhere past i32::MAX, of
the page or of a region at the same place (7.4.7.5), was refused as an
overflow, and the page with it.

Vendored patch 14 (13 is #5999): Bitmap::combine_region keeps such a
location past the page, for the four kinds of region drawn onto it, and
a refinement's offset to its reference is taken in 64 bits and held to
an i32, which leaves every reference pixel it reads outside the
reference, as the exact offset does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-03 19:14:08 +01:00
KenM76andClaude Opus 5.5 ace92f3f2f Merge origin/main
Tests only: both sides added tests at the same place; both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 11:09:49 -05:00
Dylan M. Taylor ecdd1e658c fix(audit): read content one operator at a time and charge hidden text and findings (#5875)
The faux-redaction audit held every operator of a page's content and of each
form XObject in a vector before walking it, so a stream of millions of short
operators (compressed to a few kilobytes) was materialised whole within the
page's decode budget. Each Do and gs also copied the whole XObject or ExtGState
table to pick one entry, and each annotation listed in /Annots was deep-copied
before its subtype was read.

The walk now reads operators one at a time through pdfcraft_content::ops,
borrows resource tables and annotation dictionaries through the resolved Arc,
charges a hidden line's text bytes (padding included) against the comparison
bound once per cover, and charges each finding's record against the output
bound, so a shared annotation list on many pages or withheld text cannot grow
the finding list for free.
2026-11-03 10:30:12 -05:00
AdrianTM 0d3f57e120 M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850) (#5954)
* M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850)

* M5: preserve typewriter whitespace through readers and edits (#5850)

* M5: retain direct-subtype comment listing for Fill & Sign fix (#5850)
2026-11-03 10:24:45 -05:00