Commit Graph
161 Commits
Author SHA1 Message Date
aiongg dbb71754d0 Merge PR #3497: M2: Type 0 fonts with /ToUnicode /Identity-H read their codes as Unicode
contributors/people.toml: kept both new contributor entries.
2026-10-22 01:45:33 +00:00
aiongg 88402d93a2 Merge PR #3348: M2: preserve word spaces around TOC leaders (#3086)
ROADMAP.md: kept both progress-log entries, newest first.
2026-10-22 01:45:14 +00:00
aiongg 4749f2f6bd Merge PR #3720: fix(render): hit-test selection against the glyph quad, not its bounding box (#3693) 2026-10-22 01:44:55 +00:00
aiongg 4a73cacb85 Merge PR #3116: fix(render): emit clip-only text (7 Tr) to devices for text extraction (#3109) 2026-10-22 01:44:50 +00:00
aiongg c8e81d58d3 Merge PR #3681: M2: text extraction keeps doubled letters in text condensed to 20 Tz and below (#2457) 2026-10-22 01:44:48 +00:00
PiohrunandClaude Opus 5.5 d90cee4669 fix(render): read each object stream's index once (vendored hayro-syntax patch 22) (#2427)
* fix(render): read each object stream's index once (vendored hayro-syntax patch 22)

`XRef::get_with` parsed an object stream's whole index of `objnum offset`
pairs again for every object it fetched from that stream, so reading all of a
stream of N objects took N² steps. Firefox keeps every object of a page in one
stream: the pdf.js corpus file bug1978317.pdf (65,542 objects in one stream)
took 37 s to draw and 40 s to extract its text, and was a known timeout in the
robustness baseline.

The parsed index is now kept per stream, beside the decoded stream data it
indexes (`ObjectStreamIndexes`), and never cleared, as that data isn't. A
malformed index is remembered as malformed; `/N` or `/First` that can't be
read (a reference that can't be followed while another stream loads) is not
remembered. An offset past `usize` is malformed instead of wrapping. A poisoned
lock is still used: entries are inserted whole.

Release build, Linux: bug1978317.pdf draws in 0.5 s (was 37 s) and extracts
its text in 1.9 s (was 40 s), and leaves the baseline's known timeouts;
40,000 pages in one stream open in 0.2 s (was 54 s).
`raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`
times out at 20 s with the fix reverted and passes in 0.2 s with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* test(render): an object stream offset past usize is malformed, not a panic

Review follow-up on #2427. Mutation testing by mtlcl410 found that replacing
patch (22)'s `checked_add` in `read_object_stream_index` with a plain `+`
survived every test, although in a debug build that `+` panics on the very
input it guards against (and wraps in a release build). Their test pins it:
it fails with "attempt to add with overflow" under that mutant and passes on
this branch.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:01:35 -04:00
Brandon ThomasandClaude Opus 5.5 3e3064532f Release: PdfCraft v0.11.0 (#3893)
* Release: PdfCraft v0.11.0

- Bump the workspace to 0.11.0.
- Fix main's red CI (cargo-deny): update rustls-webpki 0.103.15 -> 0.103.16
  (RUSTSEC-2026-0382) and webpki-root-certs 1.0.9 -> 1.0.10 (RUSTSEC-2026-0383),
  both reached through winprint's sigstore build dependency.
- `cargo xtask version set` now moves the workspace crates' pins in
  [workspace.dependencies] with the version; before, `cargo update --workspace`
  refused the bump and the pins had to be edited by hand.

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

* Tests: a healthy private retry may answer during the watchdog pass that scheduled it

shared_timeouts_retry_healthy_pages_privately_before_blacklisting failed on
CI (PR #3893): the try_recv that runs the watchdog spawns the private retries,
and page 1's retry has no gate, so on a loaded runner its healthy result
arrived before that same call read the channel. Accept that early answer
(page 1, no error) and use it as the healthy result; an error still fails.
Reproduced by sleeping 300 ms after the watchdog in try_recv: the old test
fails every time, the new one passes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:27:33 -04:00
O Hingst f99966a195 fix(render): hit-test selection against the glyph quad, not its bounding box (#3693)
PageText::nearest measured the distance to each glyph's axis-aligned bounding
box. In sheared or turned text that box is wider than the glyph and overlaps its
neighbours, so a pointer on a letter was also inside the box of the letter before
it, at distance 0, and the earlier glyph won. A drag ending on the last letter of
a word or line then ended one or two letters short.

A glyph whose drawn em box (quad) holds the point now wins, the nearest centre if
several do. With no quad hit the bounding-box distance still decides, so pointers
past a line end or between lines behave as before; a tie there goes to the nearer
centre rather than the first glyph.

Tests: pdfcraft-render lib 312 -> 314. The sheared and 45-degree test fails on
the old hit-test (glyph "i" resolves to the glyph before it) and passes now.
pdfcraft-ui-egui text_select: 14 pass.
2026-10-20 18:52:38 +01:00
robertobulltech 1451325a42 M2: preserve word spaces around TOC leaders (#3086) 2026-10-20 19:15:53 +02:00
Jayesh-Prajapati9 ebc7b6d801 M2: text extraction keeps doubled letters in text condensed to 20 Tz and below (#2457)
A glyph narrower than a twentieth of its height was matched as a fake-bold
redraw by a fifth of its height, so in OCR text layers squeezed to 20 Tz or
less the second l of "Hallo", and at 10 and 5 Tz the tt, ss, mm and ff and
letters repeated two or three places on, were dropped. Every glyph with a
width is now matched within half of it; only zero-width marks keep the
height-based distance.
2026-10-20 20:34:15 +05:30
Claude 62f306ba4c Merge branch 'main' of https://github.com/storytold/pdfcraft into fix/identity-h-tounicode
# Conflicts:
#	contributors/people.toml
2026-10-19 17:05:37 +00:00
FabianandClaude e45d5dca5c Tests: the parser-cancel test guards against a hang, not a 2 s limit (#1970) (#3462)
waiting_worker_can_cancel_before_initializer_returns gave its threads
2 s to start, and a busy FreeBSD VM in CI took longer. Its waits only
guard against a hang: the initializer is held until the end of the
test, so a waiter that can't cancel never returns, however long the
guard is. They now allow 600 s, like the hang guard in
page_contents_and_nested_forms_share_one_budget (#2812), which already
covers the second test named in the issue. The test polls with a 1 ms
sleep instead of yield_now, and the held initializer no longer panics
a second time when the test fails.

Checked by hand: with a 3 s delay at both threads' start the old test
fails at its deadline and the new one passes; with the waiter's
condvar wait made untimed (a real stall) the new test fails at its
guard.

Fixes #1970

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-19 13:03:13 -04:00
Earl Co 8b88a2d14b M2: fix shading bounds in Type 3 glyphs (#3178) (#3412) 2026-10-19 13:01:14 -04:00
Lcoding15andClaude Opus 5.5 978c77c583 M2: the Identity-H ToUnicode map leaves out DEL and C1 controls
crates/fonts mapped codes U+007F and U+0080–U+009F to those controls while
the renderer's text layer (hayro patch 35) gives them no text. The generated
map now covers U+0020–U+007E and U+00A0–U+00FF in the first high byte, as
the text layer does; both tests check DEL and a C1 control.
(CodeRabbit review on #3222.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:20:28 +00:00
Lcoding15andClaude Opus 5.5 96db0e3bb5 M2: Type 0 fonts with /ToUnicode /Identity-H read codes as Unicode
WSCAD (and other CAD exporters) write `/ToUnicode /Identity-H` — a name,
not a CMap stream — on composite fonts whose two-byte codes are the UTF-16
code units. Acrobat, MuPDF and pdf.js read each code as its Unicode value.
hayro resolved the name to the Identity CMap, which maps codes to CIDs only,
so such pages had no text at all: nothing could be selected, searched,
copied or edited (147 of 147 pages of a WSCAD schematic).

- vendored hayro-interpret patch 35: `Type0Font::identity_to_unicode`
- crates/fonts: the same reading for Edit text, find and redaction
  (controls and lone surrogates map to nothing; simple fonts unaffected)

Tests fail without the fix: render `identity_to_unicode_names_give_text`,
fonts `identity_to_unicode_names_read_codes_as_unicode`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:20:28 +00:00
jonathanwxh-cell 52dd6b1e40 Merge upstream/main 2026-10-19 15:21:06 +08:00
43550c448d M2: a JP2 image in a colour space the decoder refuses is drawn by its component count (#3306) (#3363)
* M2: a JP2 image in a colour space the decoder refuses is drawn by its component count (#3306)

The veraPDF fixture's JPX header names CIEJab (enumerated colour space 19),
which hayro-jpeg2000 refuses, so the image failed and the page drew blank.
When the decoder refuses the image and its colr boxes name such a space, it is
decoded again from a copy naming greyscale, sRGB or CMYK by component count,
as MuPDF and Poppler draw it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* M2: copy the JP2 stream only when a colour enum needs replacing (#3306)

Review follow-up on #3363: a stream the decoder refused for another reason was
copied for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* Renumber the JP2 colour-space patch (21): #3378 takes hayro-syntax (20)

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-19 02:50:58 -04:00
cbbeb61650 fix(parse): accept spaces between stream and its line break (#3378)
* fix(parse): accept spaces between stream and its line break

Microsoft Reporting Services writes `stream \r\n`, a space before the line break.
hayro-syntax and pdfcraft-cos took the stream data to start at the space, so the
content stream failed to inflate: the page rendered blank (white, or black with
inverted colours) and an edited save corrupted the stream. Spaces and tabs are now
skipped, but only when a line break follows.

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

* Number the stream end-of-line patch (20), as vendor/README does

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 02:50:21 -04:00
mtlcl410andClaude Opus 5.5 b955c2eefb M2: a tiling pattern drawn once shows its cell at full scale (#3349) (#3356)
* M2: a tiling pattern drawn once shows its cell at full scale (#3349)

PowerPoint writes a picture fill as a tiling pattern with /XStep 2147480100.
Fitting that step into the 3000 px cell pixmap shrank the picture below a
pixel. When what is painted can only show the first cell, the cell's /BBox is
drawn at full scale with a transparent border and padded instead of tiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* M2: a tiling cell whose scale isn't finite keeps the old path (#3349)

Review follow-up on #3356: a /BBox 1e-40 wide passes the zero-area check and
gives an infinite cell scale, which would send non-finite geometry to the cell
renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* M2: a stroke's miter counts in the one-cell check (#3349)

Review follow-up on #3356: shows_one_cell took the line width as a stroke's
reach; a miter join reaches half the width times the miter limit. The reach
formula patch (9) uses is now one function, stroke_reach, used by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

* M2: a skewed stroke's reach uses the transform's largest stretch (#3349)

Review on #3356: under a skew, a direction between the axes stretches
further than either axis, so the larger axis stretch could place a miter
tip short of the next cell when it reached it, and the one-cell path left
that cell unpainted. The reach now uses the spectral norm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:50:09 -04:00
jonathanwxh-cell e22d7066c0 M2: integrate current main into PR #3116 and add raster clip-only test 2026-10-19 09:23:19 +08:00
dab3149110 M2: a fax image with fewer /Rows than its /Height is decoded to its height (#3308) (#3316)
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 17:08:32 -04:00
aiongg 40d7b0bc47 Merge upstream main a12fe8f41 into next 2026-10-18 14:56:01 +00:00
a12fe8f411 M4: custom document properties through the engine and the tools (#2076)
* M4: custom document properties through the engine and the tools (#organize.custom-properties)

/Info entries beyond the ones the spec names are custom properties:
organize lists them (custom_info) and set_info accepts any such name
after Acrobat's checks (no standard name, no empty name, no spaces,
delimiters, / or #, at most 127 bytes); inspection lists them too
(DocInfo::custom); doc_info reports them under custom and doc_set_info
sets or clears them. The Custom tab in Document Properties and the XMP
mirror stay planned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

* M4: custom document properties list the same before and after an edit

Review follow-up. The open-time reader (render/inspect.rs) now uses the
engine's rule (pdfcraft_organize::custom_info, trimmed, empty values
dropped): text strings only, NUL/whitespace trimmed, empty values left out,
and the 256 cap counting listed entries. Before, it also listed name
values (`/Dept /RnD`) and capped before filtering, so the list could change
after the first unrelated edit. An automation test opens a file with a name
value, blank, NUL-padded, empty and numeric entries and checks doc_info's
custom list is the same before and after setting the Title.

Drops the ROADMAP.md log line, which conflicted with main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

* M4: custom properties drop NUL/whitespace padding in one pass, before the cap

Review follow-up. custom_info now trims NULs and whitespace in any mix
(" \0Lab\0 " is "Lab"), leaves out values that are only padding, and does
both before taking 256, so blank entries can't crowd real ones out. It is
the one rule: the engine uses it as it is, and the open-time reader trims
the same way. Tests: 300 blank entries before a real one, and mixed
padding, in organize and through doc_info.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:49:30 -04:00
aiongg b6b02a1b27 Merge upstream main 1952a8880 into next
ROADMAP.md, docs/roadmap.md: union of both sides' lines, each once.
parity/acrobat-features.toml: the XFA entry has both sides' evidence and notes (#2604 page areas, #2813 CLI layout).
2026-10-18 14:22:30 +00:00
jonathanwxh-cell 6c611edc61 M2: refresh clip-mode text extraction against current main (#3116) 2026-10-18 22:14:54 +08:00
jonathanwxh-cell ff5a2fc9eb M2: retain clipping text in filtered extraction (#3116) 2026-10-18 22:00:21 +08:00
MarekSpodBiedryandBrandon Thomas 25495b959b Keep a subscript or superscript in its line when extracting text (#3174) (#3244)
* Keep a subscript or superscript in its line when extracting text (#3174)

* Text extraction: only a glyph right beside the previous one gets the sub/superscript centre limit

A large negative gap (a small glyph drawn back under a large one) kept the wider limit.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:48:49 -04:00
aiongg 7f718ecc62 Merge upstream main 50f261d70 into next
ROADMAP.md, docs/roadmap.md: union of both sides' log lines and milestone rows, each line once.
parity/acrobat-features.toml, crates/xfdf/README.md: next's text, a superset of main's (#1917, #2686 plus #2688).
crates/edit/src/tests.rs: both sides' new tests (#2558 and #3084).
crates/ui-egui/src/editing.rs: the picked picture is cleared on every edit, and a new drawing commits its raster (#3149).
2026-10-18 13:12:21 +00:00
NetterSilverandClaude Haiku 5.5 5377a223e9 Extract RTL lines with numbers and punctuation in logical order (#2736)
Fixes #2667

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 03:14:51 -04:00
Guillem Hermida 12fcc64115 Tests: make timing-dependent render and UI tests deterministic (#2812)
Seven tests failed at random on slow or busy machines, main included
(FreeBSD run 38834256439 at 5fdd820b). They now count the work or wait
for the state they check; no assertion is loosened and nothing retries.

- spot_colour_images_run_their_tint_transform_once_per_sample_value
  counts tint-transform runs (exactly 256, not one per pixel) through
  vendored hayro-interpret patch (34), color::tint_transform_runs, a
  thread-local counter, instead of a 10 s deadline.
- shadings_with_large_sampled_functions_paint_quickly: no counter tells
  the flat table from the hash map (both read every sample per build),
  so the build each paint does is timed against reading its samples on
  the same thread, fastest of 8 rounds each, and must stay under 10x
  (flat table 1.1x, hash map 109x), instead of a 10 s deadline.
- page_contents_and_nested_forms_share_one_budget: the budget is
  checked by its warnings; the deadline is only a hang guard, now 600 s.
- The pen-stroke tests hold finished renders back from the view
  (DocView::hold_rasters, for tests) while they look at the settling
  stroke, then wait by state for its raster.
- files_and_quit_from_the_operating_system waits for the tab before
  deleting the file the open worker reads.
- image_pixels_follow_the_frame_while_a_corner_is_dragged waits for the
  page raster on screen before each drag.

Product fix (#1858): a released drawing stayed painted only until any
raster of its page arrived. In full screen its edit is applied a frame
later, so a raster already under way could end the preview first. A
settling drawing is now marked committed when its AddAnnotation lands,
only rasters after that end its preview, and a failed edit drops it.
2026-10-17 20:53:39 -06:00
jonathanwxh-cell d7b4d576a1 fix(render): emit clip-only text (7 Tr) to devices for text extraction (#3109) 2026-10-18 08:16:58 +08:00
aiongg 77f2b295cc Merge PR #2687: fix(inspect): report /OpenAction JavaScript in document info 2026-10-16 14:02:07 +00:00
PiohrunandClaude Opus 5.5 f65f733041 fix(render): faster sampled functions, image decodes across zoom levels and patch meshes (vendored hayro-interpret patches 31–33) (#2436)
* fix(render): sampled functions index a flat sample array (vendored hayro-interpret patch 31)

A Type 0 sampled function kept its samples in a HashMap keyed by each point of
its /Size grid (two SmallVecs per key, SipHash), and the renderer builds a
shading's functions again every time it is painted, so every paint hashed one
insert per sample. A 2,097,152-sample ramp painted 30 times took 38 s; the
pdf.js corpus's bug1721218 (InDesign) spent most of a 1.5 s page on it.

The samples now stay in stream order in one Vec, and a grid point is found by
strides (first input fastest). The interpolation keeps the same recursion and
arithmetic, so results are bit for bit the same, and a point the stream ended
before still gives no value. The repeated-paint page draws in well under a
second. The maximum sample value no longer computes 2^32 in a u32 (a panic with
overflow checks on at 32 bits per sample).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* fix(render): one decode of an image serves every size it is drawn at (vendored hayro-interpret patch 32)

The decoded-image cache (patch 24) keyed each decode by the size the image is
drawn at, but only the JPX filter decodes a reduced resolution for a size:
every other filter decodes the whole image. So a JPEG or Flate scan missed the
cache at every zoom step, was decoded again, and was kept once per zoom level.

The key now carries the target size only when JPX decodes the image, its
/SMask or its /Mask (read from /Filter or /F, erring towards keeping the
target). `InterpreterCache::decoded_image_decodes` counts the cache's decodes;
the new test draws a Flate image at five sizes and sees one decode, and a
copy whose soft mask is JPX-encoded still decodes per size. Both assertions
fail with the old keying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* fix(render): patch meshes copy their vertices' colours less (vendored hayro-interpret patch 33)

A Coons or tensor-product patch is drawn as 722 triangles over a 20 x 20 grid
of points, and every vertex's colours (a SmallVec) were produced or copied up
to five times: each grid cell interpolated its four corners itself (1,444
interpolations a patch for 400 points), cloned all six vertices of its two
triangles, and the mesh sampler cloned each vertex again to move the triangle
into device space. Profiling page 8 of the annual report in #1584 put a
quarter of its time in those clones.

Each grid point's colours are now interpolated once, a cell's second triangle
takes the vertices its first copied, and the sampler moves a patch's freshly
made triangles instead of copying them (`TriangleSampler::sample_owned`; a
free-form triangle mesh, which the shading keeps, is still copied). The same
inputs give the same values: rendering the first three pages of every pdf.js
corpus file is byte for byte the same. Release build, Linux: report page 8
773 -> 696 ms, personwithdog.pdf 149 -> 129 ms, issue13520.pdf 104 -> 87 ms.

The new test pins the colours near each corner, at the centre and between them
for a Coons and a tensor-product patch (taken before the change); swapping two
vertices' grid points makes it fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* docs: roadmap entries for faster sampled functions, image decodes and patch meshes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:50:29 -06:00
Charan Rathore b4af74dd47 fix(inspect): report /OpenAction JavaScript in document info 2026-10-16 02:40:29 +05:30
Charan Rathore 0d35035e07 Delete crates/render/src/inspect.rs 2026-10-16 02:37:47 +05:30
Tanish MankuandClaude Opus 5 13e3154ab3 fix(fonts): read the Annex D.2 alias codes as the ink they draw
#2305 fixed WinAnsi 0xAD in `crates/fonts`, and closed with two loose ends it named: the
user-visible extraction path goes through hayro, not that table, so it still reported the
invisible character; and the same Annex D.2 note makes 0xA0 the `space` glyph. This finishes
both, and makes the two tables in the product agree about the same byte.

WinAnsiEncoding gives codes 0xA0 and 0xAD the glyph names `space` and `hyphen` -- the names it
also gives 0x20 and 0x2D -- and MacRomanEncoding gives 0xCA `space`. The tables called them
`nbspace` and `sfthyphen`, whose Adobe Glyph List values U+00A0 and U+00AD are invisible
formatting characters, so extraction hid ink the reader can see: "well-known" drawn with the
0xAD byte matched no search and no redaction pattern, and a non-breaking space did not match the
space a reader typed.

Evidence. Two sources transcribe the table the same way (an Annex D implementation's docs, and
the WinAnsi.enc encoding vector R ships, which gives `/space` at octal 240 and `/hyphen` at
255), and a black-box check settles the rest: on a synthetic fixture pdftotext reads the
base-encoded codes as `a-b a b`, and keeps U+00AD for a code an explicit `/Differences` names
`sfthyphen`. We now do both. Over the 989-file pdf.js corpus the soft hyphens we extract fall
from 117 to 13 and the non-breaking spaces from 30 to 26; everything left comes from `/ToUnicode`
maps, which say what they mean and are not touched.

Scope. The base table only, in the text direction only.

- A `/Differences` entry naming `sfthyphen` or `nbspace` is a deliberate statement about one
  code and keeps its glyph list value, as pdftotext does.
- Glyph lookup is untouched, so a font program's own `sfthyphen` program is still found first.
  In hayro that is why the alias sits in the Unicode path rather than in the generated encoding
  table: `code_to_ps_name` is shared with lookup.
- Writing is unaffected: `encode` takes the lowest code showing a character, so ' ' and '-'
  still write 0x20 and 0x2D, and a caller holding a literal U+00A0 still writes 0xA0.

Tests. `fonts` pins both tables and the whole decode/encode path including the `/Differences`
case; `render` pins the extraction a reader actually gets, over WinAnsi, MacRoman and
`/Differences`. Each was watched failing with each cell and each match arm reverted in turn, so
no assertion is passing by accident. fonts 70, render 296+4, redact 49, a11y 43 and edit 156 are
green, with fmt, clippy, `xtask layers` (37 crates) and `xtask assets` clean.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 12:52:53 -07:00
ab45a1855f Keep annotations readable under reading colours (#1548) (#1557)
* fix(render): keep annotations readable under reading colours (#1548)

Reading colours remapped annotation paints as line art: the yellow
highlight fill is luminance-light, so it was mapped toward the dark
background, and Multiply then darkened the remapped-white words under
it into invisibility.

Two changes in hayro:

- The /Annots loop in interpret_page is bracketed by a new
  begin_annotations/end_annotations Device signal (patch 26), and the
  renderer's reading-colours mapping leaves annotation paints alone.
  Reading colours replace document colours, never the colours a
  comment was given — the Acrobat model.
- The page background is painted inside the page transparency group
  (patch 27), as the initial backdrop, so a Multiply highlight blends
  with the background too. With the background outside the group the
  band kept its own flat colour and the words vanished into it. White
  pages are unaffected: multiplying with white is the identity.

Regression test reading_colors_keep_highlight_annotations_readable
covers both annotation paths (/AP and the no-/AP /QuadPoints
fallback) under WHITE_ON_BLACK; parity entry a11y.replace-page-colors
updated with the behaviour and evidence.

Co-Authored-By: GLM <noreply@z.ai>

* contributors: credit @edurbs as Eduardo Soares

The contributor's own entry, chosen by them (real and display name).

Co-Authored-By: GLM <noreply@z.ai>

* #1557: record the vendored hayro patches in vendor/README.md

Main already uses hayro-interpret patches (26)-(28), so the annotation hooks become
hayro-interpret (29) and the renderer's parts hayro (17) and (18).

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

---------

Co-authored-by: GLM <noreply@z.ai>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 04:03:25 -04:00
c10e7b8f0c Preserve literal spaces in page-label prefixes (#2355)
* M0: update rustls to 0.23.46

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

* M2: preserve literal page-label prefix spaces

Keep label prefixes separate from document metadata trimming and cover literal spaces through public inspection.

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

---------

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 02:57:34 -04:00
d305e66430 Requeue render requests after parse cancellation (#2334)
* M0: update rustls to 0.23.46

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

* M2: requeue render requests after parse cancellation

Retire the active request key after releasing cancelled parse ownership,
allowing current demand to be queued again. Cover renewal before and after
the abort with deterministic queue-admission and completion regressions.

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

---------

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
2026-10-15 02:56:01 -04:00
Marco Arnulfo 210fb904cc fix(render): a soft mask fades a gradient fill again (fixes #2128) (#2132) 2026-10-15 02:46:20 -04:00
53267c66d5 M12: the geometry of page transitions (ISO 32000-2 table 164) (#2084)
pdfcraft_render::transition gives the picture of a page transition at a
moment of its run: which parts of the outgoing and incoming page to paint,
where, and how opaque, in page fractions, for all twelve styles and every
direction. Pure and tested; the full-screen presentation will paint from
it when it plays transitions.

Refs #379.


Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 02:42:51 -04:00
mtlcl410andClaude Fable 5.1 8772db0a6e M4: read page transitions and display times (/Trans, /Dur) into DocInfo (#2074)
Each page's presentation settings of ISO 32000-2 s12.4.4 are read with the
spec's defaults for what is missing or out of range: the transition style,
duration, dimension, motion, direction, Fly scale and backdrop, and the
/Dur display time. doc_info lists them; pdfcraft-cli info counts them.
This is the data full screen needs to play transitions and auto-advance;
playing and editing them stay planned.

Refs #379, #2070.


Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 02:42:04 -04:00
cyfung1031andClaude Haiku 5.5 0bf49607bf M2: heavy-sheet generator and page timing harness (item 8 of storytold/pdfcraft#623) (#2042)
* M2: heavy-sheet generator and page timing harness (item 8 of storytold/pdfcraft#623)

Adds two examples to pdfcraft-render. synth_sheet writes a synthetic A1 sheet whose content
follows the sample's layout, so timings on it can be compared with the sample. page_timing
replays the canvas's requests for page 1 and prints times, counts and an output hash. The
renderer is unchanged. Budgets are reported, not enforced.

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

* M2: sort sweep results by tile size too (item 8 of storytold/pdfcraft#623)

Tiles at the same origin but different sizes compared equal under the sort key, so the
sweep hash depended on completion order. Including the size makes the key unique.

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 02:40:26 -04:00
15d82b5b9c fix(render): a Multiply highlight inside a transparency group keeps the text (#1968) (#2027)
Highlight appearances written by other editors are a transparency group whose
content is filled with `/BM /Multiply`. Every `/Group` form got a layer of its
own, so the Multiply blended with that layer's transparent backdrop and the
layer then covered the page as an opaque block.

Vendored hayro-interpret patch (28): a group that is neither isolated nor
knockout composites its elements with the backdrop (ISO 32000-2 §11.4.7), so
when it is entered at full opacity with no soft mask and Normal blending there
is nothing for a layer to add, and its content is drawn straight onto the page.
`/I` and `/K` are read from the group dictionary. An isolated group keeps its
blank backdrop; a knockout group or one entered at partial opacity, under a
soft mask or with a blend mode keeps its layer.

Test: `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`
(the appearance drawn as an annotation and from page content, with `/I false
/K false` spelled out and with no group at all; an isolated group as a control;
a group entered at half opacity still composited as one). It fails before the
fix with the text pixel yellow instead of black. parity: view.render-transparency
cites it and says what is still approximated.


Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 02:39:16 -04:00
d3571c4019 M2: keep the raw crop minimum in physical points (#1982)
Apply the minimum after page-unit scaling without inflating small raw crops. Retain the existing unit policy and nearly-zero fallback, with regression coverage for origins, rotations and reciprocal rounding.

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 02:36:12 -04:00
e4657eb577 M2: bound ASCII85 decoding by the stream's output limit (#1796) (#1954)
The vendored hayro-syntax passed each filter an output limit, but
ASCII85 ignored it: every `z` expands one byte to four, so a Flate
stage at its limit could be grown another 4x past every budget.

ASCII85 now writes only what fits under the limit, stops with a
warning once it is reached (like RunLength, LZW and Flate), and caps
its starting capacity at the limit.

Test: decompression_bombs_stop_at_the_stream_limit gets a
[/FlateDecode /ASCII85Decode] bomb; it decoded 301,989,892 bytes
before and stops at MAX_DECODED_STREAM now.

Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 02:33:24 -04:00
Eugene HindererandClaude Opus 5.5 16d9198077 fix: render CCITT Group 4 images that declare /Rows 0 (#1903)
ISO 32000-2 Table 11 defines /Rows 0 (the default) as "height not
predetermined", but the vendored hayro-syntax decoder took it as zero
rows, so scanned pages written that way rendered blank. Treat 0 like an
absent /Rows and fall back to the image height.

Adds a synthetic regression test: an 8x4 all-black Group 4 stream,
hand-encoded from ITU-T T.6, rendered with /Rows absent, 4 and 0. It
fails without the patch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:28:36 -04:00
c5c5d0d661 M2: Add word and character counts with optional content exclusions (#1780)
* M2: Add word and character counts with optional content exclusions

Retain the complete reviewed change, including prior integration resolutions,
on current upstream.

M2: Add document word counts and semantic exclusions

* Refresh catalog hashes after merging main

---------

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 02:24:33 -04:00
Sir Clangington III.andLabDennis 2048a18e53 M2: Bound literal text search with prefix matching (#1725)
Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-15 02:21:53 -04:00
9c875656de fix(render, ui): form-heavy documents draw and fill fast (spot-colour images, widget placement, Fields panel) (#1691)
* fix(render): convert spot-colour images once per pixel value (vendored hayro-interpret patch 28)

An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or
DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is now converted to RGB
through a table of every possible pixel value instead of pixel by pixel.

A Separation or DeviceN tint transform is a PDF function, often a PostScript
calculator program, and the image decoder ran it once per pixel. Fillable
character sheets draw their background art as an 8-bit DeviceN image: one with
a 3304 x 4105 background took 3.8 s and 430 MB to draw each page (Poppler: 0.27 s),
when opening it and again after every field was filled. It now draws in 0.11 s
and 100 MB, pixel for pixel the same.

The table decodes each sample through the same `decode_sample` as the per-pixel
path, keeps `fix_image_length`'s rules for short and overlong data (the pixels
past the data are converted as before), and is used only when the image has
more pixels than the table has entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8

* fix(render): place widgets without /P in one pass over the pages

Opening a document, the inspector finds the page of a form field whose widget
has no /P by searching every page's /Annots for it, once per field: O(fields x
annotations). A form of 8,000 such fields took 1.17 s to open instead of
0.27 s. The pages' /Annots are now read once, on the first such field, into a
map from annotation to page.

A widget that several pages list is now placed on the first of them, as
pdfcraft-forms places it; the search took whichever page a HashMap visited
first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8

* fix(ui): the Fields panel keeps up with large forms

Every frame the Fields panel searched the form for each row by name, once to
sort the rows into tab order and again to find what a click selects: O(fields²)
a frame. It also laid out, drew and built the tooltip of every row, scrolled
into view or not. With 8,000 fields the app used 13.4 s of CPU for 20 pointer
moves with the panel open (0.2 s with it closed).

The rows are now worked out by `field_rows`, which pairs each row with the
first form field of its name through one map a frame, as the engine pairs
them, and a row out of view is allocated and keeps its clicks, keyboard focus
and accessibility label but isn't drawn and builds no tooltip. The same 20
moves now take 0.86 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8

* docs: roadmap entries for faster form-heavy documents

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnNgjPEDfKJM1wj17Q8Za8

* fix(render): convert each lookup-table entry on its own (vendored hayro-interpret patch 28)

Patch 28 converted the whole table of possible pixel values in one batch.
Separation and DeviceN `convert_f32` concatenate each tint result, and a Type 4
program that returns the wrong number of components is only warned about, so a
malformed result for a value the image never uses shifted every later entry. A
32 x 32 image whose only sample (255) is red drew black because tint 0 returned
one component (PR #1691 review).

`convert_each` now converts the table one entry at a time, so a malformed entry
changes only itself. The largest table (65,536 entries, a two-ink DeviceN image
over DeviceCMYK) takes about 2 ms longer.

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-15 02:20:31 -04:00