Commit Graph
408 Commits
Author SHA1 Message Date
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
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
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
Hamza Naveed cdfdd6b782 Open: measure cached objects once and by what they hold, read plain reals without a copy, and keep no /PieceInfo in inspection (#5873) (#5958) 2026-11-03 10:14:17 -05:00
Hasan N. 1849b38c5a M2: a soft hyphen a substitute font lacks draws its hyphen (#5888) (#5896)
Windows exporters map the hyphen of an Arial they don't embed to U+00AD in
/ToUnicode. The CFF standard substitute has no soft hyphen, so the CID was
taken as its glyph index and drew an unrelated glyph (an Ë, or ! in bold).
ISO 32000-2 Annex D.2 calls the soft hyphen typographically the same as
the hyphen, as patch (30) already reads WinAnsi 0xAD: draw the
substitute's hyphen. The text keeps U+00AD.
2026-11-03 09:20:16 -05:00
KenM76andClaude Opus 5.5 ee12dc684c Merge origin/main into perf/png-predictor-bytes
main took vendor patches 29-34; the PNG predictor patch is now 35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 13:01:22 -05:00
FredApps 4ffe5908c8 Merge origin/main into feat/jbig2-run-allowance
# Conflicts:
#	vendor/README.md
2026-11-02 14:15:33 +01:00
4a0b1dd158 Repair scan: an object whose header names another generation is still found (#5580) (#5584)
* A rebuilt xref finds an object whose header names another generation (#5580)

`poppler-85140-0.pdf` is 340 bytes with no cross-reference table, and the
header of its one page reads `3 18446744073709551616 obj` while the page tree
points at `3 0 R`. Before #427 the digits wrapped and 2^64 wrapped to exactly
0, so the header read as `3 0 obj` and the file opened by accident. Once an
overflowing integer became a real, the object was unreachable and the file
stopped opening at all, which `cargo xtask check` reports as a regression.

The generation is not really the point. `3 99999 obj` with the same page tree
is refused in exactly the same way on main -- the control for the new test
fails on that row first, with "page 1 does not exist" -- so this is a
mismatched generation in a rebuilt table, not an integer overflow. A table
rebuilt by scanning the file has no revision history to tell two generations of
an object apart, and a file that needs rebuilding references everything as
`N 0 R` whatever its headers say.

So, in the reconstruction path only:

  - a generation number that cannot be read is taken as 0, the value such a
    file's references use (`read_object_header`). A generation is at most
    65,535 (ISO 32000-2 §7.5.4), so there is nothing to lose by it;
  - every scanned object is also filed under generation 0, with `or_insert` so
    a real `N 0 obj` is never displaced by the alias;
  - the object is read back behind the same lenient header, since reading it
    parses the header again and the strict identifier check rejected it
    (`read_repaired_body`, gated on `repaired`).

Reading numbers anywhere else is unchanged, and a table read from the file's
own xref keeps the exact identifier match. The scan already files objects found
in object streams under generation 0, and cos's table parser already takes an
unreadable generation as 0, so this follows what the code does elsewhere.

The test covers generations 0, 99999 and 2^64 with no table and no `startxref`,
and asserts the page is found at its own size. Its fixture is built in the test
rather than taken from the corpus, so it holds without the pinned checkout.

* The generation-0 stand-in is one key, not a second object

Two consequences of filing a scanned object under generation 0 as well,
both from review.

`XRef::objects` walks the table's keys, so an object with a stand-in came out
once per key: a document whose page tree cannot be read is recovered by looking
for objects that are pages, and it reported the page twice. Enumeration now
yields one object per location, after the sort it already does. Both keys stay
for lookups; only this walk is deduplicated.

And `or_insert` never replaces, so a later `3 1 obj` moved the exact `3 1` key
to the later body while the `3 0` stand-in stayed on the earlier one -- `3 0 R`
and `3 1 R` would then read different objects. The scan now tracks which
generation-0 keys are stand-ins: a real `N 0 obj` takes the key and keeps it,
and a later header for the same number replaces a stand-in.

The tests check both through what comes out: page 2 must not exist for a
single-page file recovered without a page tree, and with two `3 1 obj` headers
the page comes back at the later body's 300x150 rather than the earlier
200x100.

* M2: the repair scan keeps its place on a non-header, and an object stream's first member is enumerated (#5580)

- read_object_header's lenient path works on a clone, so `42 3 0 obj` no longer costs the
  header after the stray number (CodeRabbit, minor).
- XRef::objects deduplicates by location and object number: an object stream and its first
  member share (offset, 0) and are two objects (CodeRabbit, minor).
- vendor/README.md: hayro-syntax patch (34) and its tests.

Both new tests fail with their fix reverted.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:02:12 -05:00
FredApps 0804a9b86c Merge origin/main into feat/jbig2-run-allowance 2026-11-01 21:10:07 +01:00
b36bdaeac6 M2: a shrunk image on a turned or mirrored page snaps to whole pixels (#5530) (#5640)
* M2: a shrunk image on a turned or mirrored page snaps to whole pixels (#5530)

hayro patch 14 (`snap_minified_image`) shrinks an interpolated image once
to its whole-pixel device rectangle and draws that 1:1, instead of
shrinking to a rounded-up size and resampling again at a fractional
scale and offset. It only accepted an upright transform (a > 0, d > 0,
b = c = 0), so every image on a page under /Rotate 90, 180 or 270 -- and
any image placed turned or mirrored by its own matrix -- still took the
double resample. That is #5530: a scanned page that matches PDFium
upright drifts and softens when the same page is rotated.

The snap now accepts every transform whose axes lie along the device's
(the eight quarter-turns and mirrors) and returns the 1:1 transform
that draws the shrunk raster from the snapped corner, one source-axis
step a whole device pixel, in whichever direction that axis runs.
Skewed and freely rotated images keep the old path.

On a synthetic page (crop box and scale on whole pixels, image at
fractional coordinates), /Rotate 90, 180 and 270 renders turned back
differed from the upright render in 72% of pixels, by up to 200 grey
levels; now they are identical. Non-interpolated images were already
identical and stay so.

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

* M2: snap only when the dropped skew is invisible, and only a resize that succeeded

Review of #5640:

- The snap judged an image axis-aligned by a tolerance on its
  coefficients (1e-4 of the scale). For a very wide image a skew that
  small still moves the far edge by more than a pixel (40 000 samples
  at 4e-5: 1.6 px), and the snap flattened it. It now measures what it
  drops: how far each off-axis term moves the far edges, at most a
  hundredth of a device pixel (SNAP_SKEW). Upright and turned alike.
- A resize that fails hands back the source raster at source size; the
  1:1 snapped transform then drew it at that size. The snapped
  transform is now used only when the shrink produced the snapped
  size; otherwise the full transform, as before.
- The mirrored case of the edge test drew a uniform image, which looks
  the same mirrored; it now also mirrors a half-black image and checks
  which side the black lands on.

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

* M2: the turned-page test draws every kind of image the snap reaches

The /Rotate oracle test drew one 8-bit grey image. Stencil masks (CCITT
scans often are one), 1-bit images and images with a soft mask reach
the snap through other paths, so it now draws all five: 8-bit and
1-bit grey, a stencil mask, a soft mask of the image's size and one of
another size. On main, every interpolated kind is off by 113-217 levels
over 63-73% of the image when turned, and so is a non-interpolated
image with a smaller soft mask (110 levels, 16%): hayro patch 15
filters such a mask whatever /Interpolate says, so it is snapped
upright and was not when turned. All are identical with the fix; the
other non-interpolated kinds were already identical and stay as guards.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 15:07:29 -05:00
FredApps cc986fa289 Merge origin/main into feat/jbig2-run-allowance 2026-11-01 21:06:29 +01:00
Grzegorz MartensandClaude Opus 5.5 b57ddb0a22 Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 20:10:05 +01:00
aiongg 4238670dd5 Merge remote-tracking branch 'o/main' into fix-5539
# Conflicts:
#	crates/create/src/tests.rs
2026-11-01 18:48:28 +00:00
Grzegorz MartensandClaude Opus 5.5 10107d943a M2: JBIG2 arithmetic near the end of an i32 doesn't overflow
A region's location, a refinement's offset, a symbol's corner, a Huffman
range line and a decoded integer of 2^31 could each overflow: a panic
wherever overflow is checked. The last four wrap, as a release build did.
Bitmap::combine saturates, which comes to the same: a side is at most
65,535, so a location that overflows is past the end of any page. What is
drawn doesn't change. Patch (12), after the numbers #5491 and #5569 take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 18:32:33 +01:00
Grzegorz MartensandClaude Opus 5.5 4303d49eea M2: a reference to a reference is what it names
`IndirectObject<f32>` read an object holding `6 0 R` as the number 6, so `/Decode [11 0 R 0]`, with
11 naming 6 and 6 holding 1, read as 6, and `/W` gave glyphs such widths. As anything but a number,
such an object was lost.

`XRef::get_with` is how every reference is read, to an object stored on its own or in an object
stream, so it now follows such objects in a loop, up to 32 references as `Document::resolve` does in
#5548. A loop takes no stack however long the chain, and a cycle runs out of references and reads as
a missing object, as a reference to nothing does: the image with such a `/Decode` isn't drawn, as
with `/Decode [99 0 R 0]`.

An object stream isn't reached that way: it has to be a stream stored as that object, and following
a reference to one stored inside another let a 1 KB file run out of stack. `XRef::objects` leaves
such objects out, or a page came out once more for each one holding a reference to it, and a comment
inside a reference counts as white space, as it does everywhere else.

Reading an object stream's index now counts against the 64 nested loads, and reads its dictionary
alone. That is older than this branch, which only made it slightly worse: a chain of object streams,
each with a member of the next for its `/N`, `/First` or `/Length`, recursed with no limit, holding a
copy of the chain at each, and aborted on 750 KB with an 8 MiB stack, or 250 KB with 2 MiB, in a test
build. Parsing the stream read `/Length` as well as `/N`, two ways to the next stream's index, and a
failed index isn't remembered, so the work doubled at each stream: 20 streams in a ring took 20 s on
`main`. ISO 32000-2 7.5.7 keeps an object stream's `/Length` out of object streams, so no legal file
has a `/Length` chain. Nothing forbids `/N` or `/First` by reference, though no writer I know of makes
such a chain. Past the limit it reads as missing unless a stream within 64 of it has been read.

`/W` and `/W2` with their list by reference are tested too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 15:28:31 +01:00
Grzegorz MartensandClaude Opus 5.5 33d7a51301 M2: a reference in /Decode, /W or a palette that leads back to itself ends
Colour spaces already had tests of this through their bases. `/Decode`, in the renderer and in image
export, `/W`, `/W2` and an Indexed space's table hadn't: each read now has such a reference in its
test, and ends.

These hold that the read ends, not what value it comes to. A reference to a reference in a number
array reads as the first number of that reference in the renderer (`/Decode [11 0 R 0]`, with 11
naming 6 and 6 holding 1, reads as 6). That is older than this branch, in `IndirectObject::read` for
an object stored on its own and `ObjectStream::get` for one in an object stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 09:31:58 +01:00
Grzegorz MartensandClaude Opus 5.5 4c815786c6 M2: an ICC profile's fallback isn't kept with the profile
The depth limit didn't hold. An ICC profile read was kept whole, with the alternate it fell back
to, and a later read took it without counting its levels. A page selecting the last of a chain
first built each space on the one before, and no read was deep: 20,000 levels, and the stack
overflowed painting with it. In the other direction, a profile met at the limit kept the fallback
it got there, and a read nearer the top got that instead of its alternate.

Both come from keeping a result that depends on the depth. What's kept now is what the profile
itself reads as, one level whatever it is, and the alternate is read where it's met.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 19:53:17 +01:00
Grzegorz MartensandClaude Opus 5.5 b71914b1de M2: colour spaces nest at most 32 levels
Reading an object already being read is refused, which ends a cycle but not a chain. 5,000 spaces
each naming the next, 180 KB of file, overflowed a 2 MiB stack and aborted the process, through a
Pattern's underlying space, an Indexed base, or a Separation's or DeviceN's alternate. An ICC
profile's alternate is the same recursion. Indexed, Separation and DeviceN could already do this;
the fix for Pattern added a way in.

A legal file goes five levels at most. All five ways through count against the one limit, in
ColorSpaceType::new, so what comes in from outside is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 19:33:43 +01:00
Grzegorz MartensandClaude Opus 5.5 a78356eefc M2.6: a spent page speaks for its JBIG2 streams only, and ends with the page
What a page has left is shared by all of it, so it says nothing about a stream that isn't JBIG2: a bad
Flate or JPEG mask dropped its picture on a page that had spent its allowance, and a failing image of
another kind raised a false "content skipped". Both ask about the stream now.

The allowance also outlived the page on its thread, and a Context used again kept it. interpret_page
sets it, and a guard ends it with the page.

Found by CodeRabbit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 15:43:05 +01:00
Grzegorz MartensandClaude Opus 5.5 663af0a169 M2.6: merge #5491's last fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 14:35:48 +01:00
Grzegorz MartensandClaude Opus 5.5 e3bc4e855f fix(render): a JBIG2 height class of no symbols decodes no bitmap
Rows are charged by the symbol, so an empty class decoded its collective bitmap for nothing: 65,535
rows a class, a thousand classes a dictionary. 806 KB took 196 s. It steps over the bytes instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 14:33:57 +01:00
Grzegorz MartensandClaude Opus 5.5 0f325d44b6 M2.6: a picture whose JBIG2 mask is past the allowance isn't drawn either
Without its mask a picture shows what the mask hides, so the page leaves it out, as export does. Any
other unreadable mask still leaves the picture, which is the renderer's tolerance for bad masks.

What an export may hold is a function of its own, tested down to the step and at the top of the range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 14:24:53 +01:00
Grzegorz MartensandClaude Opus 5.5 4aa732ea91 M2.6: what is left out past the JBIG2 allowance is said, and redaction stops
From a review of who reads a refusal.

The rule is to leave it out and say so, and it held on some paths only. An image whose JBIG2 mask is
refused no longer comes out opaque. The first image past the allowance reads as that and not as
corrupt. A page reports what it left out. The Office warning stopped blaming forms. Redaction refuses
the run, leaving the document as it was: removing a readable image whole would take what no mark
covers with it, unreported.

The 2 GiB an export holds is twice the document if that is more, so a large honest file loses nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 14:13:48 +01:00
Grzegorz MartensandClaude Opus 5.5 0ed339901e M2.6: the JBIG2 images of an operation or a page share one allowance of decoding
One image has its own limit, about 6 s to 17 s, but nothing bounded a file of thousands of images
made to reach it: an operation took that long for each, and an image sharing /JBIG2Globals decodes
them again.

Image::decode_within (hayro-jbig2 patch 11) spends from what a run may still do, starting with its
page's pixels, and charge_reading charges reading a stream and its globals, 4 steps a byte, before
anything is parsed, so an image that fails has paid too. pdfcraft_filters::decode_jbig2_within takes
it, and ImageBudget in cos is that allowance: 2^34 steps, about 1,000 scanned pages, and 2^13 more for
each byte of the file, so a long scan is read whole. An image past it spends all that's left, and
later ones aren't read, globals included.

Optimize leaves such an image as it is and counts it in Report::images_refused. Image export leaves it
out, with the reason, and stops at 2 GiB of files held. Office export shares one allowance, leaves the
picture out and says the page was truncated. Redaction removes it whole, as any image it can't read.

Rendering gives a page's images 2^30 steps between them, inside the watchdog, and each page starts
afresh in Context::new as its other budgets do (hayro-syntax patch 31, hayro-interpret patch 73).
It's a thread-local, unbounded until a page sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 13:21:45 +01:00
Grzegorz MartensandClaude Opus 5.5 78d4877ae2 Merge fix/jbig2-retained-bitmaps: what an item reads is charged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 13:21:21 +01:00
Grzegorz MartensandClaude Opus 5.5 b7ec3669cc fix(render): what a JBIG2 item reads is charged, 38 an integer
From a review of the work count. An item with no pixels was charged a step, though reading it can take
hundreds of decisions, so a 22,515-byte PDF of refined text instances of no size stayed inside the
limit and took 730 s to render. Empty height classes and export runs weren't charged at all, and 19 KB
of them took 47 s. Each integer an item reads now costs what one takes at worst, 38, so a decision
never costs more than a pixel. A full region of 10,000 instances is 0.14% of the limit, so a real file
doesn't notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 13:10:50 +01:00
Grzegorz MartensandClaude Opus 5.5 72e763ad12 fix(render): a JBIG2 symbol with no rows costs a step, and a refined instance is charged first
From CodeRabbit on the work count.

A symbol with no rows cost no work, though it is decoded all the same, so a stream of thousands of
dictionaries of such symbols, exporting none, went uncounted. Each symbol now costs a step more than
its pixels.

A refined text instance was charged after it was decoded, so one could still be 2^28 pixels past what
was left before the decoder refused. It's charged once its size is read, before anything is allocated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 11:51:15 +01:00
Grzegorz MartensandClaude Opus 5.5 43557fa046 M2: a test that a colour space referring to itself ends
The Pattern fix opened a recursion path: a Pattern's underlying space is now followed, as an Indexed
space's base always was. Self-references and cycles between the two end as a space that can't be
read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 11:44:23 +01:00
Grzegorz MartensandClaude Opus 5.5 ae94b54505 M10: export reads a /Decode array's numbers through references
Since the renderer now draws an image with /Decode [7 0 R 0], export disagreed with it. Its check of
which way round a stencil mask or a CMYK JPEG is compared the two numbers as given, and a reference
reads as none, so the file came out a negative of what the page draws.

Tests for the function arrays the previous commit's tuple fix also covers: a function's /Domain,
/Range, /Encode and /Decode are read a pair at a time too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 10:01:27 +01:00
Grzegorz MartensandClaude Opus 5.5 1352b3f0a2 Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 09:04:38 +01:00
Grzegorz MartensandClaude Opus 5.5 fa7acd3156 M2: a Pattern space's underlying space and a group of array items keep their references
Two more ways a reference was lost in the same readers. A Pattern space read its family name twice,
which took the underlying space when that was a name or a reference, so an uncoloured pattern took
its components as RGB: [/Pattern /DeviceCMYK] drew a cyan as red, direct names included (patch 72).

Tuples of array items, which /Decode, /W and /W2 are read as, took a reference at their start for the
whole tuple. A tuple can't be given by reference, so it was lost and the array ended there: an image
with /Decode [7 0 R 0] wasn't drawn, and CID widths fell back to /DW. A tuple now reads its items
directly, and each follows its own reference (hayro-syntax patch 32).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 08:58:25 +01:00
Grzegorz MartensandClaude Opus 5.5 84a2005f9e M2: an Indexed lookup table in an indirect string is read
A colour space [/Indexed /DeviceRGB 1 5 0 R] whose table is a string in object 5 couldn't be read:
the table was tried as a stream first, and a failed read uses up a reference, though not a direct
object, so nothing was left to read it as a string. The space then drew in grey from the index, so
a palette's colours were lost. The table is now read once, as whichever it is (hayro-interpret
patch 71).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 08:49:24 +01:00
Grzegorz MartensandClaude Opus 5.5 8243952569 fix(render): a JBIG2 stream's decoding work is bounded in total (hayro-jbig2 patch 10)
Each JBIG2 bitmap is bounded, but nothing bounded how many a stream asks for, so a few hundred
bytes could tie up a render worker for minutes, and CodeRabbit raised the same on #5461. Measured
through pdfcraft-cli, before and after:

- one halftone segment, a 795-byte PDF: 265 s, now refused at once;
- 66 halftones over patterns 1 wide, a 3,894-byte PDF: about 330 s, now refused at once;
- about 226,000 generic regions behind nested Flate, a 923-byte PDF: unfinished at 40 s, some
  88 hours by extrapolation, now 4.8 s;
- 8 dictionaries of symbols of no width, a 983-byte PDF: 81 s, now 0.7 s;
- about 1,048,000 generic regions of no width, a 34 KB PDF: unfinished at 90 s, now 2.6 s;
- an aggregated dictionary over 2^20 symbols, a 713-byte PDF: unfinished at 90 s, now 9.1 s;
- 10,000 instances of an 8192 × 8192 symbol, an 18 KB PDF: 64 s, now 3.2 s;
- 30 generic regions of no data: 48 s, now 4.7 s.

A stream may now ask for 2^30 units of work: four of the largest pages, about 6 s on data the
decoder predicts and about 17 s on data it can't, inside the viewer's 20 s watchdog. Decoding a
bitmap costs a unit a pixel and 4 a row, since a row costs time at any width. Copying one costs a
unit a word and 1 a row, so a tall thin bitmap costs its rows. Each item is charged before it is
decoded, apart from what is known only as it goes: new symbols and text instances are charged in
their loops. An aggregated symbol is charged for the symbol table it rebuilds. Past the bound the
image isn't drawn, as with the other caps, and the rest of the page is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 02:26:56 +01:00
Grzegorz MartensandClaude Opus 5.5 85c1005148 fix(render): a JBIG2 page's segments are bounded in what they hold and refer to (hayro-jbig2 patches 5 to 9, hayro-syntax 29)
Each JBIG2 bitmap was bounded on its own, but a few kilobytes could still exhaust memory:
nothing bounded what a page's segments hold for later ones, or how far one segment could
multiply them. Measured on black-box streams, peak memory before and after:

- intermediate regions, 16 in 774 bytes: 514 MiB, now 98 MiB;
- an 8 MiB symbol exported 1,200 times, a 2,989-byte PDF: killed at 8 GiB, now 79 MB;
- dictionaries of zero-width symbols: 781 MB, now 72 MB;
- dictionaries retaining their coding contexts, 1.1 MB: 2.89 GB, now 71 MB;
- 65,535 references to one dictionary, 78 KB: about 17 GB, now 6 MB;
- a halftone grid of 16,384 cells a side, 98 bytes: 1.1 GB, now 2.4 MB;
- a pattern dictionary of 65,535 patterns, 48 bytes: 539 MB, now 15 MB, and it still draws;
- a custom Huffman table behind nested Flate, a 1,297-byte PDF: killed at 8 GiB, now 278 MB,
  which is mostly the decoded stream;
- 11-byte segments behind nested Flate in a stream and its globals, a 4,765-byte PDF: 5 GB, now
  602 MB, which is mostly the two decoded streams;
- an Indexed image whose page header claims 2^20 a side, a 743-byte PDF: aborted asking for
  137 GB, now 23 MB;
- a chain of 4,000 /JBIG2Globals streams that are JBIG2 themselves, on a 2 MiB stack: overflowed
  it and aborted, now 24 MB.

So PageState counts what it stores: regions, pattern and symbol dictionaries and Huffman
tables, with bitmap headers, retained contexts and a charge per item, up to 64 MiB, two square
bitmaps of 16,384 a side. A dictionary's new and exported symbols count against what is left as they are
made, not once it is stored. A segment may refer to at most 2^20 symbols, checked before any are
collected. A halftone grid has at most 2^25 cells. A pattern dictionary keeps at most 16 MiB of
shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left. A custom Huffman table has at most 2^16 lines, and a
stream with its globals at most 2^20 segments and 2^22 references, checked as they are read. A
page past the bounds on a bitmap is refused when the stream is opened, since callers size their
output from its header before the page bitmap is made. And in hayro-syntax, a /JBIG2Globals
stream that is itself JBIG2 isn't decoded: globals are raw segments, so it reads as globals that
can't be decoded.

An image past a bound isn't drawn, as with the other caps, and the rest of the page is. This
bounds memory, not time: decoding work has no total yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-30 23:43:50 +01:00
songzhihui a57095c872 M2: assert exact GBK glyph rasters for installed Chinese families 2026-10-31 01:39:00 +08:00
songzhihui 9447cdc810 M2: merge main and resolve CJK viewer integration conflicts 2026-10-31 01:14:02 +08:00
KenM76andClaude Opus 5.5 f84290b294 perf(render): undo PNG predictors a byte row at a time (#5430)
The vendored hayro-syntax ran PNG predictors through the bit-level
BitChunks/BitWriter machinery TIFF predictors need, although PNG filters are
defined on bytes (ISO 32000-2 §7.4.4.4, PNG specification §9). A 993 x 936
RGB image took about 63 ms to unfilter whatever its row filters; undone on
byte slices it takes about 3 ms (Up) to 16 ms (Paeth) in a release build.

It also fixes a row that ends part-way through a pixel (three 4-bit samples
at an odd width): the bit-level path dropped its last byte, each later row
started a byte early and the picture came out sheared.

TIFF predictor 2 keeps the old path. Vendored hayro-syntax patch 29.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-30 08:23:56 -04:00
FredApps b21af3b969 Integrate 21 clean-merging PdfCraft parity PRs
Preserve each original PR head and its authorship as a merge parent.
Includes #4304 #4335 #4384 #4404 #4179 #5300 #4719 #4541 #2815 #4620 #3789 #3779 #2960 #5208 #4596 #3636 #4494 #4013 #5134 #2133 #2635.
Conflicting candidates skipped. Combined CI pending; see docs/integration-20261030.md.
2026-10-30 10:35:42 +01:00
Hamza 43337c7fd5 Drop the dead operand-overflow branch rather than revive it
Review is right that `Stack::push` always returned `Some(())`, so `overflowed`
never became true and the whole-instruction discard was dead code.

Making it live breaks the page. `an_operator_with_hundreds_of_operands_does_
not_end_the_content` (#4260) puts 300 stray operands before `0 1 0 rg` and
asserts the `rg` still reads its own three: with the discard alive the fill
stays the previous red, measured as `left: [255, 0, 0]` against
`right: [0, 255, 0]`.

The ring buffer already gives the right answer. It keeps the newest operands,
which are the ones nearest the operator -- its own -- and no operator takes
anywhere near 256 of them: `scn` takes one per colourant and ISO 32000 allows a
DeviceN space 32. So the strays fall off the back and the operator is applied to
its own operands, which is what Acrobat and Chromium do. Dropping the
instruction would lose content that is there to be read.

The dead branch and the flag are gone, `push` no longer returns a result it
cannot produce, and a test pins what the buffer does: the strays are `1` and the
operator's own operand is `0`, so the two answers differ. Keeping the oldest
operands instead fails it at 256 strays.
2026-10-29 19:06:11 +05:00
Cursor Agent 2a7e5bda26 Merge remote-tracking branch 'upstream/main' into prep/2960-merge-main
Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>

# Conflicts:
#	crates/render/src/text/tests.rs
2026-10-29 04:52:43 +00:00
FredApps 97c062a14b Complete additive integration fields and test boundaries 2026-10-29 02:40:41 +01:00
FredApps f280f3a282 Integrate KenM76 PR #5228
# Conflicts:
#	vendor/README.md
#	vendor/hayro-interpret/src/device.rs
2026-10-29 02:39:15 +01:00
FredApps 7fc2033421 Integrate KenM76 PR #5199
# Conflicts:
#	crates/render/tests/it/main.rs
#	vendor/README.md
2026-10-29 02:38:56 +01:00
FredApps 1b0e3b6110 Integrate KenM76 PR #5178
# Conflicts:
#	vendor/README.md
2026-10-29 02:38:44 +01:00
FredApps ebd7578cc3 Integrate KenM76 PR #4913
# Conflicts:
#	crates/render/tests/it/main.rs
2026-10-29 02:36:20 +01:00
FredApps b0555cb918 Integrate KenM76 PR #4898
# Conflicts:
#	vendor/README.md
2026-10-29 02:35:58 +01:00
FredApps f716bd1462 Integrate KenM76 PR #4841
# Conflicts:
#	crates/automation/tests/it/main.rs
2026-10-29 02:34:57 +01:00
KenM76andClaude Opus 5.5 367f7d5432 fix(render): a pattern before an isolated CMYK group no longer moves it
From the review of #5228. The finished CMYK group was drawn through
whatever paint transform a preceding shading pattern left on the
context, so its cyan half showed magenta. Hayro patch (27) now resets
the paint transform before drawing the group's pixels.

Two regression tests, both reported in that review:
- a pattern painted before the group (fails without the reset);
- the Color blend mode over an empty CMYK group, swept over all 256 K
  levels (failed on nine levels before #5199's vello_cpu fix was merged
  in; passes with it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-28 20:40:16 -04:00
KenM76 e518e27778 Merge #5199 (fix/color-blend-gray-black) so the CMYK K ink pass blends gray with Color correctly 2026-10-28 20:37:34 -04:00
KenM76andClaude Opus 5.5 6fcfb3f98d fix(render): an isolated CMYK group inside a knockout element is erased, not painted
main's knockout patch (hayro (22)) draws each element of a knockout group
once in erase mode, to clear what the group drew under the element's shape.
An isolated DeviceCMYK group nested in such an element still took the CMYK
path in that pass, and drew its finished pixels with Normal blending over
what should have been cleared. draw_isolated_cmyk_group now declines while
erasing, so the group's contents are interpreted in erase mode as before.

The new test fails without the fix (the centre shows red through the
magenta: [240, 63, 168] against [245, 127, 197] for the DeviceRGB control).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-28 18:20:29 -04:00