* M4: traverse large bookmark trees lazily without dropping later items
The outline walk stopped silently at 100,000 bookmarks, and opening a
document built the whole nested outline with the same cap. Both walks also
recursed once per level and overflowed the stack on a deep chain.
organize walks the outline lazily and iteratively with no item cap, and
lists it a page at a time (bookmark_page). render keeps a bounded window of
the first 10,000 bookmarks, nested at most 33 levels deep, and reports when
more exist. automation's bookmark_list pages with offset and limit, and
doc_info reports outline_more. The bookmarks panel says when bookmarks are
left out.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* M4: keep the bookmarks panel note in English
The note was a tl! label, and the coverage tests require every tl! label to
exist in each complete catalogue. This PR adds no translations, so the note
is now a plain English label in every locale. The i18n catalogues are
restored to origin/main.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* Bookmarks: translate the panel's limit notice; a numeric destination names a page in the document
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Combine files shows each file as a card (the default; Grid/List kept in
the settings, --combine-view): the first page it adds, stacked when it
adds more, its name shortened in the middle. Cards select (click, Ctrl,
Shift, arrows by card and by row), drag to reorder (one undo step; the
grid scrolls at its edges) and share the list's toolbar, selection and
history. Locked, unreadable, bad-range and unmeasurable files say why.
Thumbnails render off the UI thread, only for cards in view: at most
three render threads at once, counted until they exit (no watchdog
replacements), a 32 MB texture budget, keys that never show another
page, password or file, and one heavy step per frame on the web.
Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl
+/-/0, Ctrl+wheel or pinch over the grid, kept in the settings,
--combine-zoom), keeping the card under the pointer in place. Hovering
a card shows its actions over the top of its page: remove that file,
and a magnifier that shows it large, flipping through the pages it
adds; the same on a right click, and Space by keyboard. The toolbar
wraps between groups at narrow widths.
RenderPool gains retire(), without_replacements() and threads() so a
caller can bound the render threads that exist at once.
Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only
the text inside the rectangle drawn, instead of everything between two
points in reading order. It works with the Select tool, the text markup
tools and Redact, so a column can be copied, highlighted or redacted.
- render: PageText::glyphs_in (glyph centre inside the rectangle),
glyph_rects (one box per run, never covering unselected text) and
column_text: rows top to bottom, side-by-side pieces separated by a tab,
right to left in right-to-left rows, reading order for vertical text.
- ui-egui: a selection is a reading-order run or a column rectangle; the
rectangle is outlined while dragging and the cursor is a crosshair.
⇧-click clears a column selection (it has no anchor glyph).
- Edit ▸ Column select (edit.column_select, quick tool "column-select")
does it with a plain drag. It is the way in on Linux desktops whose
window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE,
KDE Plasma 5 by default), where the app never sees the drag.
- automation: text_extract takes rect for the same extraction headless.
- "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml
hashes updated.
- parity: new view.column-select entry with platform notes and gaps
(no selection across pages, no ⇧-click extension).
Windows: winit hands an Alt release to the system menu loop only when the
window has a native menu, which PdfCraft's does not, so Alt-drag cannot
leave the window in menu mode.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Skipping a direct array or dictionary, and the lenient repair of a
dictionary that has an object where a key belongs, recursed once per
level with no bound, so deep nesting in a trailer entry or a content
stream overflowed the stack. Count the depth on the Reader and fail
beyond 128 levels like any other malformed object.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A compressed object's container was resolved with a fresh cycle set and
without checking that it is an ordinary object, and indirect /Length
chains had no depth limit, so self-containing or chained lookups
overflowed the stack. Pass the caller's set on, require an in-file
container and cap the chain at 16 lookups. Object-stream members were
each parsed to the end of the decoded stream, so repeated or nested
offsets kept many copies of one large object: parse each member within
its own extent and skip repeated offsets.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
A signature placed with Fill & Sign could only be moved with the Select
tool. While a Fill & Sign tool was active (Sign, Initials, Add text,
Check, Date...), hovering the signature showed the placement crosshair,
a drag did nothing and a click stamped another mark on top of it.
Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks
already on the page: hovering one shows the move cursor, a click selects
it, a drag moves it and the selected mark's handles resize it. Away from
them the tool places as before, including on other comments. Image
signatures keep their live move/resize preview.
Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for
the engine's list, the same rules in the viewer's inspector), so this
also works on reopened files.
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Case-insensitive search folded the query and the page with
str::to_lowercase, which follows no locale: U+0130 (İ) becomes "i" plus a
combining dot above (two characters) and U+0131 (ı) has no case pair. So
"istanbul" never found "İSTANBUL", and "ırmak"/"kış" never found
"IRMAK"/"KIŞ" (or the reverse). The same PageText::find serves Find,
Search & Redact (phrases and word lists) and the automation text search,
so Turkish words could also be left unredacted.
Fold I, i, İ and ı to "i" and every other character with its own
lowercase, one character for one character. Case-sensitive search is
unchanged.
The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected.
Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries.
Fixes#814.
Co-authored-by: Big Pickle <noreply@opencode.ai>
* M3: gate watchdog tests without timed sleeps
* test(render): replace watchdog timing delays with gates
* Render tests: the panic test doesn't wait on the gate's finished barrier
Only an abandoned render reaches it, so waiting there hung under load.
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>
* render: cancel obsolete in-flight requests
* #623: recheck cancellation after busy publication
* #623: record rendering session in roadmap
* M2: a request cancelled during the parse doesn't leave its worker unable to render
The abort was cached as the worker's parse result, so every later request on that worker failed as
unparsable. Also drops ROADMAP edits that described other PRs.
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>
* M2: text turned at any angle reads as lines along its own baseline
The text layer sorted every glyph into the nearest of four quarter turns
and laid it out by its axis-aligned box. Text at 30-60 degrees lost its
word gaps, split into one line per word in reverse order, or interleaved
two neighbouring lines letter by letter; text set along a curve came out
in fragments.
Each glyph now keeps its drawn em box as a quad and its writing
direction. Glyphs within the layout's word-gap tolerance (sin 0.15) of a
quarter turn take the existing path unchanged. Other glyphs are laid out
in a frame along their own baseline, one flow per direction; runs that
continue each other while turning (stamps, seals) are unrolled into one
line. Turned text keeps the place in the reading order that its first
glyph had before.
Horizontal, quarter-turn, skewed and near-axis text is bit-identical
(text, rect, line_of, space_before) over pdf_engine_test.pdf, the
crates' test PDFs and 300 synthetic pages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M2: look up a turned glyph's direction among directions only, not every curved run
Curved runs can number about half the glyphs, which made grouping quadratic on hostile pages.
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>
* M2: pages with /UserUnit are shown at their size in points, as in Acrobat
/UserUnit gives the size of a page's user-space unit in points; PdfCraft
ignored it everywhere except the measuring tools, so a /UserUnit 2 page
was shown, extracted, printed and edited at half its size, and the
measuring tools disagreed with the rest of the app.
The value is now read as Acrobat reads it (the page's own entry, a number
from 1 up, at most 75,000; anything else counts as 1) in two places:
- vendored hayro-syntax Page (patch 13): render_dimensions and
initial_transform give the page in points, so rendering, tiles,
PageInfo and the text layer follow; a page without it keeps exactly
the transform it had.
- pdfcraft-model Page: user_unit(), and display_size/view_matrix in
points, so print, header/footer, watermark, background, added content
and measuring agree with the screen. The measure crate drops its own
arithmetic. Added items record the /UserUnit they were written for;
items without the record (written before) read back converted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M2: an added item's recorded /UserUnit is clamped like the page's
An infinite or huge recorded /UserUnit made the scale factor 0 and collapsed the item.
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>
* M3: harden render and CLI failure surfaces
Make content truncation observable, reject unknown CLI options, expose deterministic render work counters, and scope render failures to exact requests. Refuse silently reduced whole-page export rasters while preserving tiled rendering, and add heavy-vector and CLI regression coverage.
Co-authored-by: Codex <noreply@openai.com>
* Render: remember a failed render by page and kind again
A key with scale and tag made every zoom level and tile a new key: a
pathological page tied up a worker for the watchdog limit per request
(exhausting worker replacements) and was re-parsed each time. The
repeated-parsing check asks for a new generation and scale again.
* Export: pages too large at the chosen dpi export at the most the renderer allows, and say so
#669 made every Exporter refuse oversized whole-page renders, which
reached File > Export to image, doc_export_* and OCR: at 600 dpi any page
side over 13.65 in (Legal, A3, Tabloid) failed instead of exporting. The
exporter clamps again; Exporter::dpi_used reports the resolution a page
is drawn at, which the Export dialog's message and doc_export_png's
lower_dpi list mention. A source with reject_oversize still refuses
(CLI render keeps its strict config).
* doc_export_images: describe lower_dpi
* Render: ContentTruncated is reported once per page; the hayro-interpret patch is recorded
Nested forms, Type 3 glyphs and patterns are interpreted again and each
reported the exhausted budget; pdfcraft-render now keeps one. The warning
is part of hayro-interpret patch (18) (the page content budget): marked
in the code and described in vendor/README.md.
* CLI render: print render warnings (skipped content) to stderr
* CLI: the option check refuses unknown options only; commands report missing values themselves
text's own --page parsing (and its test on main) says what a page value
must be; the generic "needs a value" pre-empted it.
---------
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A tile of a large page renders the whole page with an offset, and hayro
stroked and filled every path on it before vello discarded what fell
outside the tile. Vendored hayro patch (9), `may_paint_canvas`, skips a
fill or stroke whose box misses the canvas: the box of the path's points
and control points (a curve stays inside their hull), grown by the
stroke's reach (half the width times the larger of the miter limit and
sqrt 2), by 0.5 user units for vello's and kurbo's approximation
tolerances, and by 2 device pixels for antialiasing. Empty paths and
non-finite bounds or miter limits are always drawn.
Renders are byte-identical with and without skipping
(`skipping_paths_off_the_canvas_changes_no_pixel`; the hidden per-thread
`set_skip_offscreen_paths` exists for that test). On #623's sample at
scale 2, a 1024 px tile takes about 38% less CPU time; whole pages are
unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Adobe-Japan1-UCS2 maps the JIS X 0208 forms of the kanji that
JIS X 0213:2004 redrew to a variation sequence (CID 1247 is 噂 U+E0100).
hayro only looked up single characters in a substitute font, so a
non-embedded Japanese font drew 噂, 逢, 溢, 祇, 箸 and over 200 other
common kanji as unrelated glyphs. A character with a variation selector,
from Adobe-Japan1-UCS2 or a /ToUnicode, now draws the substitute's glyph
for that form when its cmap has one, else its glyph for the character.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A Type 0 font that isn't embedded is drawn with a substitute (a craft-fonts
Japanese face for Adobe-Japan1). When hayro couldn't find a CID's character
in the substitute by Unicode, it took the CID for a glyph index of the
substitute, which numbers its glyphs its own way: 𠮷 (CID 13706) drew as
"Q" in BIZ UDPGothic, ㈠ and the squared katakana words as unrelated
kanji. A CID of an Adobe character collection that the substitute can't
find now draws .notdef, unless the substitute is a CID-keyed CFF font,
which is looked up by CID as before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A glyph whose ToUnicode is several characters ("ffi", Arabic lam-alef, a
letter with its marks) was split into one glyph per character, with the
glyph box cut into equal parts along view x. Turned text and vertical
columns put the parts across the line, so "office" at 90° read
"o ce\nf\nf\ni"; narrow ligatures put two "f"s close enough to be dropped
as fake bold ("of ice"); right-to-left runs reversed the characters inside
the ligature ("سالم" for "سلام").
The glyph now keeps its text and its own box, as PDFium reports every
character of a ligature with the whole glyph box.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A PDF whose AcroForm /Fields array is empty or missing listed no
fields, so none were fillable even though every widget sat on a page
with its name and value: Acrobat and the browsers fill those. The page
widgets are now adopted as fields — after the tree-listed ones, through
each widget's topmost unlisted /Parent so a field split across several
widgets stays one — and the leniency is recorded, never silent.
- forms: fields() walks /Fields as before, then adopts the unlisted
page widgets; adopted_page_fields() counts them for the note.
- render: the inspector's field list adopts them the same way, so the
Fields panel, the field-count notice and menu gating follow.
- engine: opening such a file notes "the form's /Fields list omits N
fields; they were read from the page annotations" in the repair log
(Document Properties ▸ Advanced).
Co-authored-by: GLM-5.3 <noreply@z.ai>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Check source, destination, convolution and filter-plan sizes before allocation. Preserve original image geometry when a resize exceeds the policy, while allowing valid wide sources to shrink.
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Limit function construction and calculator work, detect path cycles, and handle input-derived integer and stack errors through the existing optional failure path.
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
LaTeX `animate` PDFs store each frame as a read-only push button and drive
them from a Screen annotation. PdfCraft showed "This document contains N
interactive form fields." because the bar counted any field, and listed
the Screen annotation in the Comments panel because only Link, Widget and
Popup were filtered out.
- The form-fields bar now needs at least one field that isn't a push
button (something to fill in).
- Non-markup annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia,
3D, PrinterMark, TrapNet, Watermark) are no longer comments, both when
a document is inspected and when the comment list is rebuilt after an
edit (new pdfcraft_annot::is_comment_subtype).
Playing the animation itself (page actions, timers) is out of scope here.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The bookmarks panel decoded /Title through lopdf::decode_text_string,
which keeps a PDF 2.0 UTF-8 BOM as a visible U+FEFF, drops the whole
title when the bytes after a UTF-8 BOM are not valid UTF-8, and reads
BOM-less raw UTF-8 (written by some producers) as PDFDocEncoding
mojibake. Outline editing in pdfcraft-organize used a second decoder,
PdfString::to_text, so the two could disagree.
PdfString::to_text now decodes UTF-8 after a BOM lossily, reads
BOM-less strings that are valid non-ASCII UTF-8 as UTF-8, and strips
stray leading U+FEFF. The inspector's text strings (titles, name-tree
keys, field values) go through the same function.
The reporter's file decodes correctly on main, so this is hardening of
the decoder rather than a reproduction of that file.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M14: bound large-PDF memory use (#307)
Inspect large documents lazily, share parser/resource metadata, bound full-save
and raster retention, and cache signature candidate discovery safely.
Preserve upstream rendering deduplication, pixel alignment, signature previews,
and organizer insertion behavior, with regression coverage for their interaction.
Validated with 1,336 workspace tests, 193 parser tests, strict workspace Clippy,
30 WASM crate checks, 1x/2x GUI protocols, and same-PDF workflow comparisons.
* cos: drop the writer cache-policy probe and its test-only hooks in production paths
* cos: the temporary reader keeps parsed objects until a count or owned-bytes budget
Since #552 a stream read from the file is a view into the input, so clearing the whole parse
cache after every read saved little memory and cost most of the full-save slowdown (every
object parsed twice, indirect /Length objects re-parsed per stream). Clear only past
OBJECTS entries or STREAM_BYTES of data of the cache's own (decrypted streams, strings).
* cos: the writers refuse a document opened with a stream limit (read-only inspection)
* cos: a full save fails rather than writing null when an object reads differently the second time
* render: parsing is not timed by the watchdog; timing tests with wide margins
A slow first parse (a huge or damaged file) made the whole pool fail every page for its
lifetime. Parsing, shared or private, is now its own untimed stage, as on main before the
shared parser; rendering is timed from when parsing ends. The init-timeout tests become one
deterministic test (a 2 s parse against a 10 ms limit, shared and private). Watchdog tests
use limits and injected delays ~10x apart instead of 25-50 ms against real renders. Also:
saturating retired-worker count, main's stop-flag test runs on private parsers (the first
shared timeout is a private retry), set_queue documents that unlisted results are dropped,
and retire() documents the permanent per-worker-parser fallback.
* render: page numbers in the lazy page tree use checked conversion
* render: the lazy inspector is compared with lopdf on named destinations and object streams too
* ui: on-screen pages always render; pages turned back to stay cached; stable thumbnail tags
- Visible pages and tiles are always admitted and uploaded (the viewport bounds them); the
byte and count limits decide only how much prefetch fits. A HiDPI two-up view or a 5K
viewport left pages on 'Rendering...' for good.
- Pages no longer demanded stay cached, nearest the current page first, while the 128 MiB
allowance and the 24-page count have room: paging back no longer re-renders.
- Thumbnail density follows the demand count rounded up to a power of two, so rows scrolling
in and out of an organize grid don't change every thumbnail's tag and re-render it.
- A suspended (background) tab drains its pool's finished results instead of holding them.
- Organize drag-and-drop ends on the primary button only.
- Tests: the stale-result test uses valid pixels and checks nothing was uploaded; the
organizer wheel test runs 40 steps; regressions for the first three fixes.
* vendor: mark the hayro-syntax lock and SegmentList patches and list them as (8) and (9); clippy 1.99 as_chunks in a raster test
* render: clippy 1.99 — contains over iter().any() in a raster test
---------
Co-authored-by: Brandon Thomas <echelon@gmail.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>
* 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
* 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: James Locke <336218001+jcl-bit@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.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: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.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: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.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>
* ROADMAP: keep the JPX entry with this session's entries
---------
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M3: rasters reach egui without a copy on the UI thread
Every page, tile and thumbnail raster was copied pixel by pixel on the UI
thread (`ColorImage::from_rgba_premultiplied`), because egui's `Color32` is
4-byte aligned and a `Vec<u8>` isn't. That is 0.5 ms per 1024² tile, 5.4 ms
for a 33 MB page and 36 ms for a 197 MB page, against a 16.7 ms frame.
hayro, vendored patch 7: `render_size` and `render_into` render into a buffer
the caller allocates. `render` is `render_into` on a new pixmap, so its output
is unchanged. pdfcraft-render renders straight into `Pixels`, the same
premultiplied RGBA bytes kept in one 4-byte word per pixel. `Pixels`
dereferences to `[u8]`, so every reader of `RenderedPage::rgba` compiles
unchanged. The canvas reinterprets the words as `Color32`s and hands them to
egui as they are.
Pixels are identical: all 33 renders of #260's test file hash the same. The
UI-thread copy is gone, and rendering a 6182×8000 page takes 67 ms instead of
71–76 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M3: render_into leaves a buffer of the wrong length untouched instead of reaching vello's assert
* M3: regression test for render_into with a wrong-length buffer
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Every destination was reduced to a page index (`dest_page` in the
inspector), so a bookmark written as [p1 /XYZ 0 420 0] went to the top
of page 1 instead of putting y 420 at the top of the window, and /FitH,
/FitV, /FitR and an /XYZ zoom were ignored.
Destinations now keep their view (ISO 32000-2 §12.3.2.2):
- render: `DestView` (Top, Xyz, Fit, FitH, FitV, FitR) parsed from
explicit arrays, /D dictionaries and named destinations (/Names
/Dests and /Dests). `OutlineItem` gains `view`; `LinkTarget::Page`
carries it. Missing, wrong-typed or non-finite operands are null, a
zoom that isn't positive keeps the zoom, and an unknown type or a
/FitR without four numbers falls back to the page top.
`PageInfo::dest_fraction` maps a user-space point to the displayed
page through the crop box origin, /Rotate and the view rotation,
clamped to the page.
- ui: `DocView::go_to_dest` applies it for bookmark and link clicks
(and so for `ui.click` on the control channel): /XYZ puts (left, top)
at the window's top-left (beside the quick-action gutter) at its
zoom; /Fit, /FitH and /FitV switch to fit page, width and height with
top/left at the edge; /FitR zooms so the rectangle fits, centred. The
/FitB* content box is read as the whole page.
Tests: operand parsing incl. hostile values, a fixture with every form
(named, GoTo, wrong types, huge numbers, no page), point mapping under
crop offsets and rotations, go_to_dest state, and kittest bookmark and
link clicks checking the point lands at the window's top.
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
When a render runs past `stuck_after`, the watchdog answers with an error,
starts a replacement worker and drops the late result. The abandoned worker
still rendered the page to the end: #409's stop flag belonged to the whole
pool and was only set when the pool was dropped.
Each worker now has its own stop flag. Dropping the pool sets every worker's
flag, as before. The watchdog sets the flag of the worker it gives up on, so
that render stops at its next content operator. Its answer was already thrown
away, so nothing the user sees changes. Live workers are never stopped.
After giving up on 4 heavy renders, the process used 11.2–11.6 s of CPU in
the next 3 s with nothing left to do; now it uses none.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`cargo xtask fuzz` found a 1.1 KB page whose JBIG2 image never finished
decoding (over two minutes, flat memory). It differs from a valid file by
one byte inside a symbol dictionary's arithmetic-coded data. Past the end
of its data the arithmetic decoder keeps returning values, and two loops
in symbol.rs only move on when those values make progress:
- Export runs (6.5.10): a run of length 0 doesn't advance, and the
decoder returned 0 for ever. This was the fuzz finding.
- Height classes (6.5.5): a class whose first width is already the
end-of-class marker decodes no symbol. A read-only sweep of the crate
found this one. In a synthetic dictionary every class after the first
symbol adds 13 to the height and decodes nothing, so it only fails
when the height overflows u32, after 330 million classes (about five
seconds per image, logged); a delta of 0 would never end.
The sweep found every other loop in hayro-jbig2 bounded by a symbol,
instance or pixel cap or by the end of the data. Bounded is not cheap
everywhere: refinement bitmaps in text regions and aggregate symbols
(up to 10,000 instances of up to 2^28 pixels each) are not changed
here.
Vendored hayro-jbig2 patch (3): a valid dictionary needs at most one
height class per new symbol and `2 x symbols + 1` export runs (they
alternate, so at most one zero-length run before each symbol's). Both
loops get that budget plus `EXTRA_EMPTY_STEPS` (1024), then fail the
dictionary as invalid. Encoders don't write empty steps back to back,
so the slack only tolerates oddities.
The fuzz finding now renders in 0.2 s. Synthetic regression test in
render: two dictionaries of a few arbitrary bytes each, found by
searching for streams that decode the same way (no corpus bytes). The
height-class one is drawn as twenty images so that it stalls well past
the 20 s timeout without the patch on any machine. Each case is red
with its own budget removed and green with it.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M2: bound lopdf predictor scratch to available samples
Return empty PNG predictor frames before allocating row buffers. Reject
overflowing packed TIFF row dimensions and bound component scratch to the
samples present in the input, aligning with the existing lopdf#589 fix.
Cover hostile dimensions and valid packed rows with synthetic regressions
and record the vendored patch and its upstream proposal.
Co-Authored-By: OpenAI Codex <codex@openai.com>
* Keep main's M1 roadmap row after merging main
---------
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
`render_page` copied every raster out of vello_cpu's pixmap with
`data_as_u8_slice().to_vec()`. That is a second buffer the size of the raster
(up to 256 MB for a 64 MP page), alive alongside the first for as long as the
copy runs. `Pixmap::take` hands over the pixel vector, and `bytemuck`
reinterprets its four one-byte channels as bytes in place: the same bytes, in
the same buffer.
On a 6182×8000 px page, peak RSS goes from about 425 MB to 246 MB and render
time from 102 ms to 81 ms. Pixels are identical: every page of #260's test
file, at two scales and as a tile, hashes the same before and after.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The engine replaces a document's render pool after every edit, undo, redo,
layer toggle and comment-visibility change, and drops it on close. Workers in
the middle of a page kept rendering it to the end before finding that nobody
could receive the answer. On heavy pages that is seconds of CPU per worker,
competing with the new pool re-rendering the same pages.
hayro-interpret, vendored patch 17: `InterpreterSettings::cancelled`, checked
before each content operator. The pool sets it only in `Drop`, when its result
channel is gone, so a partly drawn page can never be delivered. Live pools,
`PageRenderer` and inline pools never set it.
7 heavy pages on 7 workers re-rendered after replacing a busy pool: 2.4–2.5 s
before, 1.1 s after (1.0–1.3 s without a replacement, unchanged).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`cargo xtask fuzz` found a 392-byte page whose ExtGState sets
`/D [[11] 0]` on a line that starts at x = 92234775807. Stroke expansion
keeps every dash, so it cut the line into billions of them and allocated
more than 5 GB before the run was stopped. Triage found the same class
in ordinary-looking geometry, and a second bug next to it:
- A tiny dash on an ordinary line (`[0.000001] 0 d` on 20 pt) passed
10 GB in under two seconds; so did `[1] 0 d` under a shrinking matrix,
and a long pattern on many short subpaths, because kurbo restarts the
pattern at every subpath. The render watchdog can skip a stuck page but
can't stop it allocating, so on a desktop this ends in an abort that
takes every open document with it.
- A dash array whose entries sum to less than zero (`[-1 -1] 0 d`) made
kurbo's search for the starting dash loop forever. ISO 32000-2
§8.4.3.6 requires nonnegative entries.
Vendored hayro patch (5, 6): `usable_dash_pattern` bounds the pieces a
pattern can cut the path into, in user space where kurbo dashes:
(control-polygon length / period + restarts) x entries per period, where
restarts are kurbo's: every MoveTo and ClosePath. Over 250,000 the stroke
is drawn solid, as it is for a pattern with a negative or non-finite
entry or a period that isn't positive. Measured in release builds: a
250,000-piece stroke takes about 16 ms and 75 MB at its peak (a million
took 80 ms and 290 MB). A non-finite phase becomes 0.
Every case now renders in about 0.2 s and a few MB. Synthetic regression
tests in render. Both were red before the fix (the first killed at
2.7 GB, the second stalled past 20 s), and four mutations each turn them
red: the first estimate (one piece per subpath) with a 100k cap runs out
of memory, a 100k cap draws a fine legitimate dash solid, dropping the
negative-entry check draws `[-1 3]` dashed, and dropping it together
with the period check hangs. (The period check alone is defence in
depth: hayro-interpret turns 0 entries into 0.01, so a pattern without
negative entries always has a positive period.)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Since #368, a non-embedded Adobe-Japan1 font with a Mincho name
(HeiseiMin-W3, MS-Mincho, ...) is drawn in document_japanese_font(),
i.e. Shippori Mincho. Shippori Mincho has none of the 63 half-width
katakana (U+FF61-U+FF9F), which are common in Japanese documents.
hayro maps the CID through Adobe-Japan1-UCS2 to Unicode, finds no
glyph in the face's cmap, and falls back to drawing the CID as a glyph
index: half-width katakana came out as unrelated glyphs (ligatures
such as fi and fl).
The Mincho substitute is now BIZ UDMincho Regular, which covers
half-width katakana (and was designed as a metric-compatible
replacement for MS Mincho), falling back to document_japanese_font()
when it isn't built in. The face used for Japanese text written into
PDFs is unchanged. Without craft-fonts nothing changes.
Tests: raster::tests::mincho_substitute_covers_half_width_katakana
(with CRAFT_FONTS_DIR: the Mincho substitute's cmap maps U+FF61-U+FF9F
and 日本; it lacked all 63 half-width katakana before; skips without
craft-fonts). skrifa 0.44 is a dev-dependency of pdfcraft-render for
it, the version pdfcraft-fonts already uses. The parity entry
view.render-cid-fonts cites the test.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Read the date prefix at a checked UTF-8 boundary and leave malformed dates
unchanged. Commit auxiliary metadata only after successful inspection so
an escaped inspector panic cannot discard the renderer's page geometry.
Add synthetic date-boundary, UTF16BE annotation and independent failure
regressions.
Co-authored-by: OpenAI Codex <codex@openai.com>
The canvas rebuilds its render queue from every page, tile and text layer it
is still missing, and calls `set_queue` whenever that list changes, which is
whenever any answer arrives. Requests a worker had already popped were still
missing, so they went back into the queue and an idle worker rendered them a
second time. With one worker every other page was rendered twice; with seven
workers about half of all renders were duplicates. A slow page could also take
every idle worker in turn until the watchdog fired.
The pool now remembers the requests its workers have taken until `try_recv`
hands out their answer. A queued request equal to one of them is skipped, and
an answer removes queued copies of its request. Equal requests to one pool
always produce the same answer (its bytes and settings never change), so
nothing the caller sees changes except that answers arrive sooner.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Text: find the word and the line around a glyph
PageText gets word_at(i) and line_at(i). Each returns the first and
last glyph of the word or the line around glyph i, or None when i is
past the end of the page's text. A word stops at a word gap, a blank
glyph or a line end. A line is the run of glyphs with the same line
number.
The canvas's double-click used its own loop for this, which indexed the
glyph, line and gap arrays directly. It now calls word_at, which reads
them with get(). Double-clicking selects the same words as before.
Test: render words_and_lines_around_a_glyph, on two lines with a blank
glyph, a word gap and a line break.
* Issue #295: three clicks select the line, four clicks the page
With the Select tool, quick clicks on text widen the selection like
Acrobat: two clicks select a word (as before), three the line, four or
more all the text on the page. A third click used to clear the
selection.
egui counts only up to triple clicks and reports a fourth quick click
as another triple, so the view keeps its own count of the current run.
A single click resets it and a pause ends it.
With the markup tools (Highlight and the like) and Redact, a
double-click already turns the word into a mark at once. They stop at
the word, so more clicks don't stack line or page marks.
Tests: ui-egui quick_clicks_select_a_word_then_the_line_then_the_page
and quick_clicks_with_the_highlighter_mark_only_the_word.
parity: view.text-select cites the tests. The notes say what is
missing: paragraph selection, and Ctrl/⌘+A across the whole document
(it selects the current page).
* Clippy: as_chunks in the signature preview test (follow-up to #355)
Clippy 1.99 rejects chunks_exact with a constant chunk size
(chunks_exact_to_as_chunks), so `cargo clippy --workspace --all-targets
-- -D warnings` fails on main since #355. Use as_chunks, as #360 did
for the test above it.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
#260 (page 2): text in a Japanese CID font that the PDF doesn't embed
(HeiseiMin-W3 with UniJIS-UCS2-H, Adobe-Japan1) drew nothing, while
MuPDF, Poppler, PDFium and Acrobat draw it in a Mincho face. hayro asks
its font resolver for a substitute with the CID collection in the
query, but the renderer never installed one, so hayro's default
answered with a Latin standard-14 font that has no Japanese glyphs.
Release builds have embedded Japanese faces from craft-fonts all along;
the renderer just didn't use them.
RenderConfig::settings now installs a resolver: for an Adobe-Japan1
fallback query it returns a craft-fonts face (Shippori Mincho for
Mincho names, BIZ UDPGothic Regular or Bold for Gothic names, the
descriptor's serif flag otherwise); every other query keeps hayro's
default. hayro already maps such fonts through the Adobe-Japan1-UCS2
CMap to Unicode and the face's cmap. Without craft-fonts nothing
changes. Other CJK collections are untouched: the pinned craft-fonts
has only Japanese faces, and its later Chinese face is Noto CJK, which
AGENTS.md §1.1 rules out. pdfcraft-render now depends on pdfcraft-fonts
(L3 to L2).
Tests: raster::tests::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face
(with CRAFT_FONTS_DIR: 日本 in HeiseiMin-W3 and HeiseiKakuGo-W5 now
draws; it drew 0 dark pixels before; skips without craft-fonts, as the
Linux CI job has them) and raster::tests::japanese_font_names_pick_mincho_or_gothic.
The parity entry view.render-cid-fonts cites the test and notes what is
still missing.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
ISO 32000-2 §8.5.4: W and W* mark the current path to become the clip
once the path-painting operator that ends it has painted it with the old
clip, and n is only one such operator. The vendored interpreter applied
the clip in n alone. After S, s, f, F, f*, B, B*, b or b* it was never
applied, so everything painted afterwards ignored it, and it stayed
pending until some later, unrelated n (even after Q) clipped content
that should show. Issue #260's test file uses `W* S` on pages 3, 5 and
9: a shading meant for a circle flooded page 3, an image meant for a
rounded rectangle and nested clips on 5 and 9 spilled out.
Every painting operator now applies a pending clip after painting and
clears it (apply_pending_clip, shared with n). The now unused
Context::clip getter is removed.
Test: raster::tests::clipping_path_applies_after_any_painting_operator.
W and W* with each of n, S, s, f, F, f*, B, B*, b, b*: the later red fill
stays inside the square, and a stroking operator's outer half still
shows outside it (painted under the old clip). A clip used by S doesn't
linger for a later n, inside or after q/Q. On main it fails at `W S`.
MuPDF, Poppler and PDFium agree on every case. Page 3 of #260's file now
differs from PDFium by 0.84/255 on average (was 164). Vendored patch
(16).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Layers: run set-layer-visibility actions, keeping radio-button groups
The engine can apply a set-layer-visibility action (SetOCGState,
ISO 32000-2 §12.6.4.13) to a document's layers: each ON, OFF or Toggle
in order, naming the layer by its optional content group, so a toggle
sees the state the changes before it left. With /PreserveRB (the
default), a layer turned on turns off the other layers of its
radio-button groups, now read from /OCProperties /D /RBGroups; turning
one off leaves them alone. The renderer is rebuilt once per action, not
once per layer.
Like the Layers panel, this changes the view, not the document.
Tests: engine set_layer_actions_toggle_in_order_and_keep_radio_groups
and set_layer_actions_change_what_renders (pixels before and after),
render radio_button_layer_groups_are_read.
* Links: run set-layer-visibility actions
Clicking a link whose action is SetOCGState showed "SetOCGState actions
run in the JavaScript engine (M6)" and did nothing. Links now read the
action (/State, also when indirect, and /PreserveRB) and clicking runs
it; the Layers panel follows. Hovering shows "Set layer visibility"
(translated in the ja, es, zh-hans and zh-hant catalogs), and doc_info
describes the action in its link list.
Tests: render links_read_set_layer_actions, automation
doc_info_describes_set_layer_links and ui-egui
links_set_layer_visibility.
* Forms: push buttons run set-layer-visibility actions
A push button whose mouse-up action is SetOCGState counted as having no
action, so clicking it did nothing. Buttons now read the action as
links do, and clicking runs it.
Tests: forms push_buttons_read_set_layer_actions and ui-egui
push_buttons_set_layer_visibility.
parity: edit.action-set-ocg-state is partial. The notes say what is
missing: creating the action, bookmarks and other triggers, /Next
chains, JavaScript ocg.state, and the Layers panel keeping radio-button
groups itself.
* French: Set layer visibility
The French catalog is coverage-checked since #291.
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>