Commit Graph

89 Commits

Author SHA1 Message Date
Nicholas Yoannou 92c9de7aad
Comments: line breaks saved as CR show in comment text (#1113)
Acrobat saves the line breaks in a comment's /Contents as CR, e.g.
"first line\r\rsecond paragraph". egui draws CR as nothing and only breaks
lines at LF, so the comments panel, the hover popup and the edit box ran
the paragraphs together ("first linesecond paragraph").

Both readers of comment text (pdfcraft_render::inspect on open and
pdfcraft_annot::summaries after an edit) now turn CRLF and CR into LF.

Regression tests: carriage_returns_in_comment_text_become_line_breaks in
annot and render (both fail before this change).
2026-10-11 14:46:05 -04:00
cyfung1031 473d5d48d4
M2: overlapping text runs stay whole; placement in arrange, bounded checks (#748)
* M2: overlapping text runs stay whole, laid out in arrange

Two text runs that overprint each other on one baseline were merged into one segment and sorted by x, so their letters came out interleaved ("ADleplhtaa  ebpestialo gna zmemtaa"). Runs are now ranges of the filtered glyph list that arrange() already works on. Glyphs of runs that collide (overlap by more than a quarter of the line height) stay whole and are placed by their left edges; runs that only interleave along the line keep glyph-by-glyph order. Glyph placement happens when a segment is emitted, into scratch storage that every segment reuses.

layout() is unchanged: it still calls arrange() and takes the original glyphs by the returned indices. Turned text needs no separate path, because arrange_flow() already lays it out in its own baseline frame.

Tests: overprinted runs at 0 and 37 degrees, a run drawn over a line, a combining mark drawn last, and runs that only touch stay as they were or come out whole.

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

* M2: a run that redraws an earlier run is printed once

Doubled, shadowed or fake-bold text draws a whole line again at (almost) the same place. The per-glyph fake-bold filter only looks four glyphs back, so a redraw of a whole run got through and printed twice.

After runs are identified, a run is dropped when an earlier retained run has the same glyph texts over the same places (each glyph within a fifth of its height). Runs are keyed by a fingerprint of their glyph texts, with each text hashed with its length so glyph boundaries count, and by a grid cell. Every earlier candidate with the same key is checked, so a run that does not match cannot hide a later redraw. Dropping a run compacts the segment list only; glyphs are never removed from the input, so the returned indices are still mapped through the fake-bold filter's `kept`.

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

* M2: merge zero-width marks into a segment in one pass

Each zero-width mark was inserted with a search from the start of the placed glyphs and a Vec::insert, so a segment of m marks and n glyphs cost O(m·n) comparisons and shifts. Marks are visited in x order, and the position each one goes to never moves back, so a single cursor over the placed glyphs puts them all in. The output is unchanged, including when a colliding component leaves the placed order non-monotonic in x, because the cursor stops at the same first glyph whose x is greater than the mark's.

A 16000-mark segment takes 225 ms, the same as main (608 ms with the per-mark insert).

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

* M2: a dense overprint joins its runs instead of sweeping every pair

The collision sweep compared each glyph with every later glyph that started before its right edge. When many glyphs start at one x, as in a heavily overprinted line, that is quadratic in the glyphs of the segment.

The sweep now has a budget of COLLIDE_PAIRS_PER_GLYPH (16) pair tests per glyph of the segment. Ordinary text overlaps each glyph with about one neighbour, so it never reaches the budget and its output is unchanged. A segment that needs more is dense: its text runs are joined into one component and placed run by run, which keeps every run whole. The fallback is one pass over the runs, so the work for a page is O(glyphs) pair tests plus the sorts.

On a page of 2000 overprinting runs (12000 glyphs) the sweep took 157 ms, against 53 ms on main; with the budget it takes 65 ms. The output is identical to the sweep's, since every run in these pages overprints every other.

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

* M2: look up forced word breaks by binary search

Each glyph of a segment was checked against every forced word break with breaks.contains(), so a segment with many overlapping rows cost O(glyphs x breaks). The breaks are sorted once, after collision placement, and looked up by binary search.

The lookup returns the same answers, so the output is unchanged. The new test has 60 overlapping rows, each of which starts a word.

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

* M2: find each word gap's glyph by a position table, not a scan

For every word gap, the glyph that takes the space was located by scanning the segment's output (idx.iter().position), so a long line with many gaps cost O(glyphs x gaps). This was already on main; the change makes it linear.

Each glyph's place in its segment's output is now written into a page-sized table as the segment is emitted, and the gap reads it. The table is never cleared: a segment writes every entry it reads before reading it, and the glyphs of a segment are a permutation of the same set it wrote. The space goes to the same glyph as before, so the output is unchanged.

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

* M2: check a redraw against at most 32 earlier runs

A run is checked against every earlier run with the same fingerprint and grid cell. Runs that share a key but do not redraw each other are all kept in that chain, so a page of N such runs costs O(N²) checks. The chain is new in this change, not in main.

Each run now checks at most REDRAW_CANDIDATES (32) candidates, newest first. A run past the limit is kept, as it would be if no earlier run matched. Runs that redraw one of the newest candidates are still dropped, and a test checks both sides of the limit: a redraw of the newest of 40 look-alike runs is found, and a redraw of the oldest is kept.

On 4096 non-redrawing runs (69 632 glyphs, all kept by both versions) the check cost 200 ms, and the bound brings the page back to 286 ms, against 272 ms on main. Output is identical.

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:41:15 -04:00
pechnikov2026-star 6ed0e66848
render: don't let a hostile page-tree /Count abort inspection (#956)
* render: don't let a hostile page-tree /Count abort inspection

Found by cargo xtask fuzz on Windows (seed 42): a 355-byte PDF whose
/Type /Pages node carries /Count 4294967295 aborted pdfcraft-cli
check-one with a 48 GiB allocation (4294967295 x 12 bytes) - an abort,
which the engine's catch_unwind guard cannot catch. AGENTS.md §4
requires a malformed PDF to produce an actable error, never a crash.

The lopdf Structure impl delegated get_pages to lopdf's own page-tree
walk, which trusts /Count and pre-allocates by it. The trait's default
walk (depth cap, seen-set, checked page numbering) handles the same
input fine, so the override is simply dropped and the bounded walk
counts the real pages.

Comes with a synthetic regression test (/Count 4294967295, one real
page); cargo test -p pdfcraft-render, clippy -D warnings, fmt --check
and an end-to-end check-one run all pass.

* Drop the duplicated use super::* in the structure tests

---------

Co-authored-by: pechnikov2026-star <pechnikov2026-star@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:33:02 -04:00
cyfung1031 dce9b9f912
M2: keep non-interpolated minified images sharp (#635)
* Fix #624: preserve hard edges for non-interpolated images

Select nearest-neighbour for Hayro's image pre-minification when /Interpolate is absent or false, retaining Catmull-Rom for true. Add a synthetic regression covering default, false, and true behaviour and record the vendored patch.

Co-authored-by: Codex <noreply@openai.com>

* M2: test non-interpolated minification with exact colours

The #624 regression used alternating black and white columns. Nearest 8→4 sampling reads only white columns from that pattern, so an image skipped entirely still passed, and the check only asked for near-black or near-white pixels.

Replace it with 2×2 colour blocks whose exact device output is asserted, with /Interpolate omitted equal to false, and /Interpolate true still blending. Add exact cases for gray, a non-integer 10→6 minification, and a matched soft mask. All pass on this head. On origin/main the same block image renders with blended mid-tones (for example 12eb12ff where pure green is expected), so these tests fail without the #624 change.

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

* M2: plan nearest-neighbour pre-resize without filter limits

Non-interpolated images are pre-resized with nearest-neighbour, which copies source pixels and needs no filter tables, crossed intermediate or scratch. The preflight still applied the Catmull-Rom checks to it: a source wider than 2^20 pixels, or a filter plan over 128 MiB, was refused, and once such a source was wider than the backend's u16 limit the image was skipped.

resampling_byte_len and image_resampling_size take the image's interpolation flag. Nearest keeps the source and target buffer bounds and refuses any scratch it reports. Filtered images keep their limits unchanged, and the existing image_resampling_size tests pass true.

Measured: a 1.5M-pixel-wide RGB row drawn at 750×4 device pixels renders 0 non-white pixels on origin/main (the image is skipped) and all 3000 pixels with this commit. The 1.1M-pixel gray case is covered by wide_non_interpolated_sources_draw_after_shrinking.

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

* M2: keep non-interpolated Type 3 glyph images nearest

Minified images inside Type 3 glyphs were drawn with ImageQuality::High, a bicubic sampler, whatever their /Interpolate. A non-interpolated image had already been pre-resized with nearest-neighbour, and bicubic sampling of that result blended it again. High is now used only for interpolated images, as elsewhere.

Before this commit the new test saw an interior glyph pixel of [28, 28, 27] (not a palette colour) for a 4.6 × 2.3 px glyph of a palette image; after it, every interior pixel is a palette colour. /Interpolate true still blends.

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

* M2: nearest-sample masked minified images straight into premultiplied RGBA

A non-interpolated image with a matching soft mask used to build a full-resolution RGBA copy, nearest-resize it, then premultiply the target. For a minified image most of that copy was discarded.

nearest_premultiplied_rgba reads colour and mask from the same source pixel, selected with PicScale's nearest index (same fixed-point centre sampling as ResampleNearestPlan) and premultiplies only the target pixels. It falls back to the previous path if its target buffer cannot be allocated. Interpolated images, unmasked images and magnification are unchanged.

New test: a ten-column gray ramp with a soft mask drawn six pixels wide must pick the same columns for colour and mask.

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

* M2: record the #624 image patch in vendor/README.md

The hayro row listed the #624 regression test but not the patch itself. Add patch item (10) covering the nearest pre-resize and its planning, the Type 3 bicubic restriction and the premultiplied soft-mask path, and list every regression test that covers them.

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 13:31:18 -04:00
mlkonrad 7f1b632519
M2, #1060: axial and radial shadings draw as native gradients (backport of hayro 0.8) (#1061)
InDesign writes fades as luminosity soft masks whose group is one
extended axial `sh` over the whole page. hayro 0.7 sampled every such
shading pixel by pixel through the shading function, so a report page
with 40 of them took 10-15 s at 144 dpi.

Backport hayro 0.8.0's `gradient` module: an axial or radial shading
that a native gradient draws exactly becomes sRGB stops (within 0.01
per channel, a stop at every stitching bound), painted as a vello_cpu
gradient. Upstream's half-pixel shift is left out: it suits vello_cpu
0.3 but moves 0.0.8's gradients off pixel centres.

Page 4 of the reported file: 10.8 s -> 1.0 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:12:16 -04:00
snipeTR 0344ed44a0
fix(ui): bookmark, comment and command filters match Turkish İ/ı in any case (#937)
The bookmark filter, the Comments panel filter and the command palette
lowercased with str::to_lowercase, which follows no locale: "İSTANBUL"
became "i̇stanbul" (a combining dot added) and "ı" has no case pair, so
"istanbul" never found the bookmark "İSTANBUL ŞUBESİ" and "imzalayın" never
found a comment "Lütfen İMZALAYIN".

They now fold with pdfcraft_render::text::fold_case, the folding Find and
Search & Redact use (made public here): I, i, İ and ı count as one letter
and every other character folds to its own lowercase.
2026-10-11 13:06:22 -04:00
cyfung1031 dd046a25b2
M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623) (#797)
* M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623)

Page content was decoded whole, and the decode stopped at
MAX_DECODED_STREAM (256 MiB), so the last ~10 MB of the #623 sample
was dropped without a warning. Top-level page content is now read a
window at a time, so the decoded content is never all in memory:

- the content tokenizer reads windows and holds back an instruction
  that runs past one; Reader records reads that touch the end of the
  data, including reads made through clones, so an instruction is
  only decided once it has been seen whole;
- the Flate decoder is resumable and keeps its own 32 KiB history, so
  it pauses at any output size and back-references do not depend on
  what the caller has kept;
- page content may decode to max(256 MiB, 64 x its compressed size),
  counting each distinct stream once; content that reaches that
  budget is cut and the page reports ContentTruncated, as it did not
  before;
- forms, tiling patterns, Type 3 glyph procedures and generic streams
  keep their existing budgets and caps.

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

* M2: Avoid duplicate page content raw reads

* M2: Document page content byte reuse

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 12:17:09 -04:00
pauliusiv123-rgb 4666e74691
Text extraction: word gaps follow position on the line, not drawing order (#1062) (#1074)
A line's glyphs are sorted left to right, but the space for a word gap was put before whichever of the two glyphs came later in the content stream. When the right-hand run was drawn first, the space landed inside the left run: 20.0 19.4 came out as "20. 019.4". The same rule put the space one letter early between right-to-left words drawn left to right.

Gaps are now marked by position on the line and reversed together with right-to-left runs.
2026-10-11 11:53:16 -04:00
cyfung1031 9ccf77bdf6
M4: traverse large bookmark trees lazily without dropping later items (#876)
* 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>
2026-10-10 17:17:13 -04:00
taylorandrew07-lab 5268047a33
Combine files: a thumbnail grid, card sizes and card actions (refs #611) (#777)
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>
2026-10-10 16:44:15 -04:00
mlkonrad 65e89aefb2
M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765)
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>
2026-10-10 16:41:27 -04:00
Ikki Nishihara 74be522c18
hayro-syntax: cap the nesting of direct arrays and dictionaries (#772)
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>
2026-10-10 15:54:26 -04:00
Ikki Nishihara 559859ca61
lopdf: bound object lookups through compressed containers and object-stream members (#771)
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>
2026-10-10 15:51:25 -04:00
LloydNicholson 5ad2ecfef9
M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822)
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>
2026-10-10 15:47:58 -04:00
snipeTR 259002da97
fix(text): Turkish dotted and dotless i match in case-insensitive search (#892)
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.
2026-10-10 15:32:07 -04:00
muraa-p e0625cd5cf
fix(text): match phrases across repeated whitespace (#814) (#831)
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>
2026-10-10 15:06:43 -04:00
cyfung1031 5d59060486
M3: make render watchdog regressions deterministic (#699)
* 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>
2026-10-10 08:33:40 -04:00
cyfung1031 742c42ab63
M2: cancel obsolete in-flight requests (#702)
* 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>
2026-10-10 06:22:24 -04:00
cyfung1031 7fafadc3a2
M2: text turned at any angle reads as lines along its own baseline (#735)
* 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>
2026-10-10 06:19:54 -04:00
cyfung1031 d94da2038a
M2: pages with /UserUnit are shown at their size in points, as in Acrobat (#722)
* 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>
2026-10-10 06:19:31 -04:00
cyfung1031 997b82d49c
test(render): cover pattern recursion cycles (#710) 2026-10-10 06:14:05 -04:00
cyfung1031 7a8445376e
M3: compare stitched render partitions with whole page (#697) 2026-10-10 06:13:50 -04:00
cyfung1031 f109676f77
test(render): cover form recursion cycles (#709) 2026-10-10 06:13:12 -04:00
cyfung1031 a211e334a0
fix(render): key soft masks by inherited context (#716)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:11:45 -04:00
cyfung1031 18e5db5e6d
test(render): cover inherited form resource contexts (#707) 2026-10-10 06:03:36 -04:00
cyfung1031 d84d6c859a
test(render): pin output across worker counts (#705) 2026-10-10 06:03:24 -04:00
cyfung1031 306bbcedf9
M3: harden render and CLI failure surfaces (#669)
* 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>
2026-10-09 22:32:48 -04:00
cyfung1031 a5e378fd4b
M3: tiles skip paths that can't paint them (item 5 of #623) (#670)
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>
2026-10-09 22:15:31 -04:00
cyfung1031 ef9b553f55
M2: kanji that Adobe-Japan1 maps to a variation sequence draw in non-embedded fonts (#662)
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>
2026-10-09 22:05:06 -04:00
cyfung1031 d63436ed10
M2: a character a substitute font lacks draws .notdef, not an unrelated glyph (#661)
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>
2026-10-09 22:04:26 -04:00
cyfung1031 d67e79dd15
M2: a ligature stays one glyph in the text layer, in every writing direction (#660)
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>
2026-10-09 22:04:11 -04:00
Eduardo Soares a2f0fb7363
Forms: fields listed only as page widgets join the form (#649)
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>
2026-10-09 21:43:09 -04:00
Streetblock 1f0466fcae
M2: bound image resampling buffers before allocation (#592)
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>
2026-10-09 18:53:56 -04:00
Streetblock 04f6b1a634
M2: bound PDF functions and reject invalid calculator operations (#581)
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>
2026-10-09 18:53:42 -04:00
Brandon Thomas 275cbcf323
Refs #169: no form bar for push-button-only documents; Screen is not a comment (#615)
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>
2026-10-09 17:49:54 -04:00
Brandon Thomas 90bb2f14ad
Refs #142: decode outline titles with BOMs and raw UTF-8 consistently (#614)
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>
2026-10-09 16:52:07 -04:00
skylar taylor-barrick dbd0748177
Reduce large-PDF memory growth (#307) (#419)
* 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>
2026-10-09 14:38:01 -04:00
Streetblock bd4736d39c
M2: bound page-label traversal and output (#480)
* 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>
2026-10-09 14:12:16 -04:00
Phil Kwek 1399d38ea3
Fix: High-precision float mantissa overflow in hayro-syntax, causing warped display of some PDFs (#199) (#427)
* 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>
2026-10-09 14:11:59 -04:00
jcl-bit f5c99ce059
Never crash: mesh shadings can't exhaust memory or run for minutes (#412)
* 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>
2026-10-09 14:11:43 -04:00
jcl-bit e88def3465
Never crash: decompression bombs can't exhaust the renderer's memory (#411)
* 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>
2026-10-09 14:10:50 -04:00
Streetblock a5202e11fc
M2: normalize JPEG2000 samples without overflowing (#575)
* 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>
2026-10-09 14:09:56 -04:00
cyfung1031 63363ec438
M3: rasters reach egui without a copy on the UI thread (#555)
* 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>
2026-10-09 12:28:46 -04:00
LloydNicholson 4e725dcee2
Bookmarks and links go to their destination's position and zoom (#556)
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>
2026-10-09 12:19:14 -04:00
cyfung1031 cc17deb634
M3: the watchdog stops the render it gives up on (#522)
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>
2026-10-09 12:19:04 -04:00
jcl-bit ee06583523
Never crash: JBIG2 symbol dictionaries can't loop without end (#408)
`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>
2026-10-09 09:22:21 -04:00
Streetblock 8c90a8d1fd
M2: bound lopdf predictor scratch to available samples (#470)
* 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>
2026-10-09 09:21:21 -04:00
cyfung1031 d6c2a694ec
M3: rendered pixels are moved out of the pixmap, not copied (#404)
`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>
2026-10-09 09:15:55 -04:00
cyfung1031 e130af2ef4
M3: renders of a dropped render pool stop at the next operator (#409)
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>
2026-10-09 09:15:47 -04:00
jcl-bit 8ce0a905bf
Never crash: dash patterns can't exhaust memory or hang the renderer (#405)
`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>
2026-10-09 09:15:38 -04:00