Both page-label readers decode each range's prefix into a string of its
own: render's inspection for its range table, Organize for the ranges it
returns. Ranges may share one prefix object, so a small file could ask
for a copy per range: within the 200,000-entry tree limit, about 200 MiB
of copies of one 1,024-byte prefix. Decoded prefixes now take at most
4 MiB in all (MAX_PREFIX_TOTAL_BYTES, beside MAX_LABEL_TOTAL_BYTES). Past
that the tree is refused before any label is made, as the other tree
limits refuse it.
Tests: 5,000 ranges sharing a 1,000-byte prefix are refused in both
readers (relabelling such a tree changes nothing), and exactly 4 MiB of
prefixes is still read.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M4: make page-label generation lazy across large documents
Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count.
The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* M4: drop the aggregate page-label cap from document inspection
Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted.
MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* M4: keep the inspector's 4 MiB page-label cap until its labels are lazy
Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* hayro-syntax: fix float mantissa overflow on high-precision coordinates (#199)
jsPDF outputs double-precision floating-point coordinates with 16+ decimal
places in /MediaBox. In hayro-syntax, read_inner accumulated float mantissa
digits into u64 using wrapping arithmetic. Integer mantissas exceeding
u64::MAX wrapped modulo 2^64, severely corrupting page dimensions
(e.g., 3874.96 pt became 185.61 pt, rendering as a warped ribbon).
Accumulation now uses checked arithmetic: trailing fractional digits
exceeding u64 capacity are safely ignored without incrementing decimal_shift.
This preserves full IEEE-754 f64 precision while preventing overflow for
numbers with arbitrary numbers of decimal places.
Includes:
- Unit test in hayro-syntax with reported values and a 50-digit fraction.
- Synthetic PDF regression test in pdfcraft-render verifying inspect().
- Vendored patch (6) documentation in vendor/README.md.
Fixes#199
* M2: bound page-label work and output before applying labels
Share bounded PDF Roman and repeated-letter formatters between inspection
and editing. Bound tree traversal, raw and decoded prefixes, individual
labels and aggregate label output, with checked numbering arithmetic.
Stage inspection labels before applying them and retain physical page
numbers with a warning when optional labels cannot be processed. Reject
unsupported label edits before changing the document tree.
Add synthetic limit, cycle, Unicode, numbering and atomic-edit regressions.
Co-Authored-By: OpenAI Codex <codex@openai.com>
* Never crash: mesh shadings can't exhaust memory or run for minutes
`cargo xtask fuzz` (with the dash, JBIG2 and decompression fixes in
place) found a page whose Coons patch shading (type 6), a 147-byte
stream with 1-bit coordinates decoded onto +-40000, took the renderer
past 3 GB. hayro-interpret samples mesh shadings (types 4-7) pixel by
pixel over each triangle's device-space bounding box into a map, with
nothing but the u16 cast bounding it: up to 65536 x 65536 entries for
one triangle. A synthetic single patch passed 6 GB in six seconds; ten
thousand triangles that each cover half a 1000 x 1000 page ran for
minutes. Two rounds of review of earlier versions found more: every
patch was cut into 722 triangles (about 150 KB) before anything limited
them; a lattice mesh with `/VerticesPerRow 0` pushed empty rows forever,
and one with 4294967295 read the whole stream into one row; a mesh
painted a thousand times was triangulated a thousand times; and the
first caps cut content without a word.
Vendored hayro-interpret patch (18):
- `ShadingPattern::encode_within(area)`: the vendored hayro renderer
passes the area it will actually draw (the path's bounding box on the
canvas, which it already computes) plus a pixel for texels at a
fractional edge, and triangles are sampled only inside it, so the map
holds at most that area's pixels.
- `TriangleSampler`: at most 8 visits per pixel of that area plus 4 per
triangle in it. A triangle's bounding box is about twice its area and a
sub-pixel triangle's is at most 2 x 2 pixels, so tiling meshes and
small dense ones (a patch in a few pixels is 722 triangles) still
shade completely; overlapping ones stop.
- Coons and tensor-product patches are triangulated one at a time.
- Meshes hold at most `MAX_MESH_PATCHES` (2^16; a 200 x 200 gradient
mesh is 40 thousand) patches and `MAX_MESH_TRIANGLES` (2^20)
triangles, and a lattice needs two (ISO 32000-2 8.7.4.5.6) to 2^19
vertices per row.
- A page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25; the 200 x 200
mesh is 29 million) mesh triangles in all, reset per page like the
existing paint budget.
- Each of these cuts logs a warning.
`encode()` keeps its signature; with no area it samples within a fixed
2^24 visits.
Synthetic regression tests in render:
- `mesh_shadings_far_off_the_page_render`: a Coons patch and a triangle
mesh decoded onto +-40000 render and the rest of the page draws; a
patch on the page, the part of the huge one that covers it, and a
patch filling a 4 x 4 pt rectangle as a pattern are all shaded.
- `many_overlapping_mesh_triangles_finish`: 10,000 triangles each
covering half of a 1000 x 1000 page finish, and are drawn.
- `mesh_shadings_with_many_patches_or_no_rows_finish`: 40,000 patches
beside the page, and a lattice with `/VerticesPerRow 0`.
Red on main (out of memory, killed at 3.9 GB). Each part is pinned by a
mutation: no visit budget stalls past 60 s; no clip leaves the covering
patch unshaded; no per-triangle allowance leaves the small patch
unshaded; triangulating all patches first runs out of memory (killed at
5.8 GB); no rows check runs out of memory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Never crash: decompression bombs can't exhaust the renderer's memory
The renderer's parser (vendored hayro-syntax) decoded Flate, LZW and
RunLength streams with no output limit. A 2 MB page whose content
stream inflates to 2 GB, or whose 8 x 8 image does, took `pdfcraft-cli
render` and `text` past 3 GB in under a second, and a few more MB of
file scale it further. On the desktop that is the first-page render of
an emailed PDF, and the allocation-failure abort takes every open
document with it. Our own layers already bound single streams (lopdf's
load-time decoding at 256 MiB, cos at 1 GiB), so `info` stays at a few
MB; the renderer path didn't. Random mutation almost never makes a
high-ratio stream, so `cargo xtask fuzz` doesn't find this; found by
hand while triaging the fuzz run.
Vendored hayro-syntax patch (6):
- `decode_limit` gives each filter an output limit, and Flate (both
flate2 and the fallback inflater), LZW and RunLength never write past
it (the buffer can still grow to under twice it):
`MAX_DECODED_STREAM`, 256 MiB, lopdf's figure; or, for the last filter
of an image whose size, bits per component and components are known,
twice its pixel bytes (a PNG predictor tag byte per byte at most),
capped at 1 GiB. Earlier filters in a chain produce compressed data
and get the general limit.
- `Stream::decoded_within(max)` for callers that budget.
- A page's `/Contents` array shares one `MAX_DECODED_STREAM` (naming one
bomb twenty times decoded 5 GB), reserving exactly what it appends.
Vendored hayro-interpret patch (18):
- The content a page holds at once (its contents, and the forms, tiling
patterns and Type 3 glyph procedures being painted) stays within
`MAX_PAGE_CONTENT`, 512 MiB, reset per page like the existing paint
budget. Forms nest fifty deep and each level kept its content while
the next ran: a form painting itself around a 256 MiB stream would
have held over 12 GB. Each level gives its bytes back when it is
dropped, so content painted again and again keeps drawing, and a
stream without filters (borrowed from the file) costs nothing.
- Type 0 sampled functions read every sample into a 4-byte value and
then a table entry: 256 MiB of 1-bit samples asked for over 8 GB. They
now take at most 2^21 table points and 2^23 values (the largest real
tables, DeviceN tint transforms such as 33^4 points x 3 outputs, are
1.2 million and 3.6 million), counted with checked arithmetic, and
read no further than `/Size` asks. `/Size 0` no longer underflows
building the default `/Encode`.
Three rounds of independent review found the aggregate cases, buffer
doubling past the limit, a chain/predictor shape the first image limit
would have truncated, the Type 0 amplifier, and a first, cumulative
budget that would have dropped legitimately repeated content, and a
first Type 0 cap low enough to refuse real tint transforms; all fixed
here.
Measured in a release build (render; OS peak commit):
- content stream inflating to 2 GB: 389 MB, 0.3 s
- 8 x 8 image inflating to 2 GB: 4 MB, 0.2 s
- /Contents naming it twenty times: 516 MB, 0.3 s
- a form painting itself around it: 644 MB, 0.4 s
Synthetic regression tests in render (flate2 is a new dev-dependency
there, already in the workspace):
- `decompression_bombs_stop_at_the_stream_limit`: 288 MiB bombs through
flate2, the fallback inflater (a stored block with a wrong length
check, which flate2 refuses and the fallback accepts), LZW and
RunLength each decode exactly 256 MiB, and at most 128 bytes as an
8 x 8 image.
- `page_contents_and_nested_forms_share_one_budget`: three /Contents
references decode one stream's worth; a self-painting form finishes.
- `content_painted_again_and_again_keeps_drawing`: a 2 MiB form painted
300 times draws the 300th time.
- `sampled_functions_with_billions_of_samples_are_refused`.
Ten mutations each turn them red: no limit at all; no limit in flate2,
the fallback, LZW, RunLength or for images; an unshared /Contents limit;
no page budget; no refund on drop; no Type 0 sample cap. (Taking a form
out of the budget no longer compiles: its content is the budget guard.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M2: normalize JPEG2000 samples without overflowing
Use bounded integer sample scaling for 1-32-bit JPX images and reject empty dimensions before chunking. Cover public decoding and continued page rendering with synthetic regressions.
Co-Authored-By: OpenAI Codex <codex@openai.com>
* Never crash: decompression cut at its limit is logged, not silent; vendor README renumbered after #575
---------
Co-authored-by: philkwek <philkwek03@gmail.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: James Locke <336218001+jcl-bit@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now
PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the
PrintCraft settings and crash-recovery folders to the new name, and both apps read settings
saved under the old storage key, so upgrades keep recent files, preferences and unsaved work.
The signing test fixtures keep their PrintCraft subject names (they are binary).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The outline import, bookmark lookup and page-label code use `first`/`last`, a final `Err` and a
default range instead of `expect`/`unreachable!`. The crate now denies the panic lints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stable clippy 1.99 denies chunks_exact/chunks_exact_mut with a constant
size (32 sites), which failed the test job on main and every open PR.
Behaviour unchanged; MSRV 1.90 has as_chunks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>