Review follow-up: in a whole-XDP stream, a config/acrobat/acrobat7/
dynamicRender path inside xfa:datasets/xfa:data came before the real
config packet and decided the form type. The lookup now follows direct
children only: the config child of the xdp root (or the config packet
itself), then acrobat, acrobat7 and dynamicRender.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
Review follow-up. The config packet is decoded as UTF-16 (byte order mark,
or `<` written as two bytes) or UTF-8 before the scan, as crates/xfa reads
packets. The scan walks tags, skipping comments, CDATA sections, processing
instructions and declarations, and takes the value only at
config/acrobat/acrobat7/dynamicRender, so the same name in form data, in a
comment or elsewhere in the config no longer counts.
The engine test now adds the element to the fixture's own config packet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
An XFA form with no AcroForm fields was always classified as dynamic, so
opening it laid the template out into new pages and appended them as a
revision: the pages the user saw, and any later save, were PdfCraft's
layout rather than the file's own pages, with nothing saying so. Adobe
Designer writes <dynamicRender>forbidden</dynamicRender> in the config
packet of a static PDF form; such a form is now static and its pages are
kept. /NeedsRendering still makes a form dynamic, and with no word from
the config a form without fields stays dynamic as before.
The config packet is found in an /XFA array or a whole-XDP stream, read
with a 16 MiB decode cap, and scanned for the one element.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
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.
reading_lines grouped the joined operators by the stream their first token
is in and walked each group on its own. A marked-content sequence opened in
one stream (its /ActualText dictionary, or the whole BDC) and closed in the
next lost its replacement text: the export read "Glyphs" where every reader
of the joined streams reads "Replacement text".
The page's operators are now walked once. Each shown text still counts as
the stream its operator starts in, and text from another stream still starts
a new line, as before.
place_tagged wrapped the page's content in q/Q only when no Wrap stream
existed yet. Content appended after an earlier wrapper (by another tool, or
an older OCR layer) that left a cm, a clip or text state set then moved,
hid or distorted every later header, Bates number, watermark, added text,
flattened annotation and redaction cover.
Placing a mark on top now reads the page's actual operators instead of the
/PCMark tags. When the content parses cleanly with balanced q/Q and closed
text objects and paths, the stretch from the first stream that leaves state
set is wrapped, once, at a stream boundary no comment runs into. Content
that is already self-contained gets no new wrapper, so repeated marks don't
pile up.
Content that can't be read that precisely (stray bytes, unbalanced q/Q, an
unfinished text object or path, too large) is wrapped whole as before,
rather than refusing: redaction stamps its covers after removing the text
and can't back out. A q that content leaves open is closed after it, where
closing it changes nothing already drawn.
The wrapper's close now starts with a newline, so a comment at the end of
the content before it can't swallow the Q. Removing marks strips only
wrapper pairs that end the list, keeping one that later content still
relies on, and recognises the older close bytes too.
Pieces::join separated a page's content streams with a newline, which ended
a comment left open at the end of one stream. hayro, MuPDF and Poppler join
them with a space, so the comment runs on into the next stream and hides its
first line. Edit text, object_list, the redaction audit, redaction and the
Office/HTML export therefore saw text and boxes that nothing draws, and
redacting unrelated text on the page made the hidden content visible: the
rewritten stream dropped the comment.
- Pieces::join uses a space, so every reader of the joined streams agrees
with rendering.
- The parser records, per piece, whether a top-level comment runs into it
and out of it (boundaries only, not every comment). A redaction rewrite
of a piece ends an incoming comment before its first operator and leaves
an outgoing one open, so hidden content stays hidden.
- Reading for export parses the streams joined, instead of one by one.
- The redaction tag check compares the joined streams the same way.
- Pieces::piece_of uses a binary search.
RUSTSEC-2026-0370/0371/0372 were published today for zvariant, zbus_names and
zvariant_derive. All three are unmaintained notices rather than vulnerabilities,
and none has a fixed release to move to. They reach the tree as the D-Bus stack
behind accesskit_unix -> atspi (Linux accessibility) and as a dev-dependency of
pdfcraft-platform, so nothing ships in the other targets' binaries.
This is why main went red today; no dependency version changed.
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
GoToR file specifications go through craft-docpath: network, device and
object-manager paths (\??\, /??/) are refused without touching the
filesystem, relative targets resolve against the source document's
folder, and a result that is not absolute is refused rather than read
against the working directory. Files named on the command line open
under their absolute path, so their relative links keep resolving.
An attachment opened as a tab is named with the same plain file name as
its Save suggestion, and Export certificate names the file the same way
from the certificate subject. Writing files into a chosen folder refuses
any name with a separator, a drive, or that is . or .., so no caller can
write outside the folder.
* fix(ui): validated signature display, snapshot tab reuse
Signature widgets with layered appearances baked the unverified state
into the page, so a valid signature kept reading 'Signature Not Verified'
while the banner and panel said valid. Validated verdicts are now drawn
into the icon and caption layers in the display copy only: the signed
bytes are untouched, saving keeps the original appearance, and files
without those layers or without a valid verdict render exactly as before.
Other valid signatures still get the verdict mark over their widget,
sharing one style with the Signatures panel, and hide it again once the
appearance itself shows the verdict.
View signed version and View revision opened a new tab on every click,
and every tab showed the document title, so repeats piled up identical
tabs. Repeats now reuse the snapshot tab, snapshot tabs read like their
source plus the suffix in the tab strip, window title and Combine picker,
and carry a notice bar naming the revision with a Close that asks about
saving first.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Address review comments on validated appearances and trust refresh
Count every field when checking shared appearance streams, so a
caption shared with an unvalidated field is never rewritten for the
valid one alone.
Pick the icon slot by drawing instead of length, and only when a
single layer qualifies. Bare rectangles never count as the icon, and
ambiguous or missing slots fall back to the overlay badge.
Revalidate and invalidate together through one helper, so trusting a
certificate always drops rendered pages. Trust alone still marks
nothing dirty.
Tests: mixed-verdict sharing, longer decorative borders, competing
and doubled layers, capture-before preservation, trust invalidation
without zooming, and signed Session round trips with rendering,
saving and passwords.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Prove the icon slot geometrically and cover real layered signatures
A lone line drawing is still not the icon: frames and borders span the
box while the status mark lives in a corner of it. The icon now has to
sit strictly inside the appearance frame and cover at most half of it;
anything else falls back to the overlay badge. Widgets without a
measurable frame veto the rewrite the same way.
Tests: parsed extents and inhabitant checks as units, both reported
border repros plus a frameless widget, and real layered signatures
built with cms::sign_detached through Session open, trust, rendering,
save and reopen, plain and password-protected.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Identify the icon by shape instead of geometry
A lone line drawing is still not the icon, but size and position were
the wrong signals: the real status mark is large, and geometry blocked
it. Frames, borders and panels are rectangles by nature while the mark
is not, so a drawing made only of axis-aligned rectangles is decoration
however it is stroked. Singularity, shared-stream and template checks
are unchanged; anything ambiguous still falls back to the overlay badge.
Tests: rectangularity as a unit, nested line-drawn frames, and a
triangular fixture mark in place of the rectangular one.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Budget the appearance walk and keep the marks with the bytes
The walk that finds a signature's icon and caption capped depth,
layers and fields, but not the work: a dictionary whose entries all
point at the next one is visited once per entry per level, so a small
hostile file never finishes opening. The walk now counts its visits
and gives up, and a field whose tree cannot be read to the end stops
the whole pass, because a layer we never read may be the one two
fields share and a rewritten shared layer puts one field's verdict on
another field's page. The sharing count is exact again with that: two
widgets of one field pointing at the same appearance claim it once,
or the field vetoes its own rewrite.
A mark used to outlive the bytes it described. A failed display write
left the page showing the file's own caption while the verdict still
claimed its state was drawn; a metadata edit dropped the mark while
the page kept drawing the state, so the badge landed on top of it. The
write fallback now drops the marks and logs the loss, and metadata
carries them over.
Tests: a self-referential appearance tree, which never finished before
the budget, two widgets sharing one appearance, a metadata edit that
keeps its mark, a save that goes through the writer instead of
short-circuiting on the opened file, and the snapshot bar's Close
button pressed for real.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Cite the new regressions in parity, restore main's installer note
The appearance entry now cites the bounded walk and the save through
the writer, and says what a tree that cannot be read to the end does.
The installer entry carried main's older text, which still claims
signed installers are planned; main's newer note is taken verbatim.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* M9: preserve rounded decoration beside raster status glyphs
* M9: place appearance progress beside signature history
* M9: retain appearances with ambiguous curved layers
* M9: keep signature verdicts in viewer overlays
Defer generic appearance rewriting: caption text and a unique drawing do not establish an icon role. Preserve original artwork for saves, image and text exports, and both print modes while retaining snapshot reuse and trust refresh. Cover chamfered borders and mixed signer/caption streams through signed Session round trips.
---------
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
* 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>
A cover counted only when it hid 40% of the line's words or box, so a
black box over one number in a long line, and an unapplied redaction
mark over it, audited clean while the number was still extractable. A
cover that hides half of any one word now counts as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Resolve independent progress-document conflicts while retaining upstream updates and the existing regression-tested fix. Refresh the relevant milestone notes; feature scores remain unchanged.
Fresh checks: cargo fmt --all -- --check, git diff --check, and layering/assets/parity gates. Focused production/test files and lockfiles match the prior green CI head. Full CI will rerun on this merge.
Resolve independent progress-document conflicts while retaining upstream updates and the existing regression-tested fix. Refresh the relevant milestone notes; feature scores remain unchanged.
Fresh checks: cargo fmt --all -- --check, git diff --check, and layering/assets/parity gates. Focused production/test files and lockfiles match the prior green CI head. Full CI will rerun on this merge.
`CLAUDE.md` asks for a progress-log line and a status line at the end of a session.
- The Annex D.2 alias codes, in both tables (`13e3154a`).
- Reading order in the text oracle (`50cbf5fa`), with gap 12's revision history.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`cargo xtask text-oracle` reported one number: word F1 against `pdftotext`. F1 is a bag of
words. It catches a character read wrong, a glyph not read at all, a font we cannot map -- and
it is blind to order, so a page emitted back to front scores a perfect 1.000. Reading order is
most of what text extraction has left to get wrong: a column read into the middle of its
neighbour (#2387), a figure's labels threaded through a paragraph (#2586), a form's value parted
from its label.
The hole is not hypothetical. Reviewing #2431, a change that moves text and alters nothing else,
**word F1 called 26 of the 29 files whose output changed identical**, while scoring the word
sequence separated 18 improvements from 6 regressions and named the shape of the regressions
(forms: `prefilled_f1040.pdf` 0.672 -> 0.481, a checkbox's number parted from its label). The
harness could not see the only thing that change does.
So a second score per file: the longest common subsequence of the two word sequences, as a share
of the longer one. It shows up in the first three corpus files -- `Test-plusminus.pdf` scores F1
1.000 and reading order 0.875, the same forty words in a title block that we read down the
column and `pdftotext` reads across.
Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages
is plenty of signal; the run says how many files that bit, so the cap cannot quietly become the
measurement. It reports; it does not gate, and like the pixel score it is a *disagreement* with
Poppler rather than a verdict -- in the #2431 measurement the same split scored as a win on one
file and a loss on another purely because `pdftotext` makes it in one place and not the other.
The function's doc comment, `crates/oracle/README.md` and `docs/gaps.md` gap 12 all say so.
`words` now takes the sequence rather than re-splitting the text, so both scores read the same
tokens. `docs/file-format-parity.md` claimed plain-text export was good "(word-F1 0.98)" in a row
whose own column is reading order; it now says the two are scored separately.
Verified: `cargo xtask text-oracle --limit 60` end to end; `cargo test -p xtask` 49 passed; fmt
and clippy clean. The unit test was watched failing with the score blinded to order (sorting both
sides made it 1.000 where it must be below 0.7). The corpus-wide first reading is not in this
commit: the machine is shared with a parallel build and the full sweep was stopped twice for
memory.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#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>
* Fix main UI regressions in paging, fonts and test expectations
* Wait for asynchronous dropped-document preparation in regression
---------
Co-authored-by: Codex <codex@openai.com>
the Revert prompt to zh-hant.tsv; merged together, the catalog held both
copies and `bundled_catalogs_are_consistent` (and the tests that load
zh-hant as a reference) failed on main. Keep #1750's wording (字型大小,
「」 quotes, as the rest of the catalog) and drop the other copy.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>