Commit Graph
2338 Commits
Author SHA1 Message Date
aiongg 60132bbfbf Merge upstream/main into fix/coderabbit-c4-core 2026-10-22 20:29:34 +00:00
Brandon ThomasandClaude Opus 5.5 39844f06b5 Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.12.0
2026-10-22 15:39:48 -04:00
aiongg 8555d6f159 fix(parity): cite the renamed renderer-check test and drop fixed redaction gaps 2026-10-22 15:17:44 -04:00
aiongg 01aa6bcc17 docs(redact): describe the overlay, text state, description and inline image rules 2026-10-22 15:17:44 -04:00
aiongg b72078871a fix(content): end inline images where the renderer ends them
Filtered inline image data has no length, so its end is an EI chosen
among candidates, and the content parser chose by a different rule
than the renderer's tokenizer (hayro-syntax): the renderer also takes an
EI with no whitespace before it, and checks a candidate by reading what
follows as operators. With image data such as "7FEI 1 0 0 1 200 0 cm",
the renderer ran a cm that redaction read as image data, so text was
drawn 200 pt from where removal and its verify pass looked.

The parser now takes each inline image's end from the renderer's
tokenizer (already in the tree through pdfcraft-cos), so both read the
same operators after the image; a test runs both on the same fixtures.
Only where that tokenizer reads no image (no EI it accepts, or
parameters it can't read) does the parser fall back to its own rules,
counting the image in Parsed::ambiguous_images, and applying redactions
refuses such a page as unreadable.
2026-10-22 15:17:44 -04:00
aiongg 7db37911ed fix(redact): clear descriptions above removed content and annotations
Structure cleanup cleared alternate text, actual text and expansions
only on the element that directly owned changed marked content, and an
element that lost an annotation reference kept its descriptions unless
it lost every kid. An ancestor's /ActualText covering a redacted span,
or a link element's /Alt repeating the address of a removed link, stayed
in the saved file. Elements that lose an annotation now always lose
their descriptions, and every ancestor of an affected element (found
through both /P and the tree's /K, which may disagree) loses its own.
An element written in place above one with a description to clear
refuses the operation, as other in-place cases already do.
2026-10-22 15:17:44 -04:00
aiongg 2cdb39503e fix(redact): compare marked content without a copy per enclosing MCID
Finding which marked content a redaction changed copied every operator
into the buffer of each MCID enclosing it, so content nested n deep was
held n times: a modest stream of nested BDCs could exhaust memory.
Each operator inside marked content is now serialized once, and each
MCID keeps the ranges its sequences span. The bytes compared are capped;
MCIDs past the cap count as changed, so their descriptions are cleared
rather than kept.
2026-10-22 15:17:44 -04:00
aiongg cccd435a53 fix(redact): bound image decoding by the declared raster size
Clearing the pixels of a partly covered image decoded its stream with
the global 1 GiB allowance and only then cut it to the raster length its
Width, Height and bit depth declare. A small Flate stream could make
every such image allocate and inflate far more than it can use. Decoding
now stops at twice the raster plus a byte per row (filter-chain and
predictor overhead) and a little slack; an image past that is removed
whole, as an undecodable one already is.
2026-10-22 15:17:44 -04:00
aiongg be64a60c87 fix(redact): unlink removed XObjects from surviving forms' resources
After applying redactions, removed images and forms were unlinked from
page-tree resource dictionaries but not from the resources of forms the
page still draws. A form sharing the page's original resource dictionary
(a common producer layout) kept the removed image reachable, so a full
save wrote it. The walk over surviving forms now drops XObject entries
for removed objects that the form's own content does not draw, and fails
closed when that cannot be determined.
2026-10-22 15:17:44 -04:00
aiongg 22aa6d5f9a fix(redact): place glyphs with the inherited and ExtGState-set text state
The redaction interpreter started every form XObject with an empty text
state (no font, size 0, no spacing) although a form inherits its caller's
graphics state, and it ignored the /Font entry of an ExtGState set by gs.
Text shown that way was placed at the wrong positions, so removal and
the verify pass could both miss glyphs under a mark. Forms now start
from the caller's state, and gs sets the font and size as Tf does.

The engine's renderer cross-check test used the gs case as its example
of interpreter/renderer disagreement; it now runs the check directly on
an unredacted page, which also covers an input stream labelled as a
redaction overlay.
2026-10-22 15:17:44 -04:00
aiongg 6ca4431b50 fix(redact): check every input content stream, whatever it calls itself
Both checks for text left under applied redaction marks (redaction's own
verify pass and the engine's renderer cross-check) skipped any page
content stream whose dictionary said /PCMark /Redaction, so that the
overlay's label would not count. An input file could put that label on
its own content stream and keep it out of both checks. Applying
redactions now records the overlay streams it adds (stamp returns the
new stream), and only those are skipped.
2026-10-22 15:17:44 -04:00
aiongg f42fb0ad49 fix(forms): a deleted field leaves the calculation order
Deleting a field took it off its pages and out of the field tree but left
its entry in the form's /CO array. That reference kept the field
dictionary, its value and its widgets reachable, so a full save after
applying redactions over the field still wrote the redacted value.
2026-10-22 15:17:44 -04:00
aiongg 3e968c8aa2 chore(a11y, fonts): satisfy clippy in the CID width ranges and a test helper 2026-10-22 14:15:50 -04:00
aiongg 6b62585339 fix(optimize): bound the search for fragment runs on a page
A run of images that is not one picture was searched again from each of
its groups, collecting the rest of the run every time, so many images drawn
over one another made a page quadratic. The search reads at most 16 groups
per content operator (at least 65 536) and leaves the rest of the page as
it is once that is spent.
2026-10-22 14:15:50 -04:00
aiongg dba51fc823 fix(optimize): refuse a JPEG whose own size differs from its dictionary's
The pixel reader capped the image size the dictionary gives, then decoded
the JPEG at the size its header gives and compared afterwards, so a small
image dictionary over a huge JPEG allocated the huge one first. The reader
takes the size from the JPEG header and refuses a mismatch before decoding.
2026-10-22 14:15:50 -04:00
aiongg fcedb15689 fix(export): find font size and colour slots through a map
The body-size tally and the RTF colour table searched every slot seen so
far for each block, and the RTF writer searched the colour table again for
every paragraph and cell, so a document with many distinct sizes or colours
made export quadratic. Both keep their first-seen order and look slots up
in a hash map.
2026-10-22 14:15:50 -04:00
aiongg 5609bcbe37 fix(fonts): wrap text measuring each word once
Line wrapping built and measured the whole candidate line for every word,
so a long field value or annotation text at a tiny font size, with many
words on one line, took quadratic time and copying. The wrapper keeps the
line's width as it goes and measures each word, space and broken
character once.
2026-10-22 14:15:50 -04:00
aiongg 75c4275810 fix(fonts): look up CID and width ranges by binary search
Code-to-CID and /W range lookups scanned every record in declaration
order, once per glyph and once per explicit ToUnicode entry while a font
was read, so a few thousand records multiplied by as many codes took
billions of steps. Both tables are indexed once into stretches whose
first-declared covering record wins, the precedence the scan had, and the
/W ranges stop at the CMap entry limit.
2026-10-22 14:15:50 -04:00
aiongg 1cc392e5b0 fix(fonts): cut ToUnicode destinations to the CMap limit
A sequential bfrange stores one destination and builds every covered
glyph's text from all of it, so one huge destination and many repeats of
its codes made text extraction copy megabytes per glyph. Destinations keep
at most 256 UTF-16 units (the format's 512 bytes), and marked-text
extraction stops asking for a glyph's text once its id has all the text it
keeps.
2026-10-22 14:15:50 -04:00
aiongg 38211588c7 fix(a11y): find every figure's bounds on a page in one pass
Figure listing decoded and parsed a page's content once per figure on it,
so many small Figure elements pointing at one page multiplied the work.
The figures are grouped by page and one pass gathers the bounds of every
marked-content id they own; what a sequence draws also counts for the
sequences around it, as before.
2026-10-22 14:15:50 -04:00
aiongg 110b7c678a fix(a11y): read the enclosing marked content in one step per operator
Marked-text extraction and figure bounds searched the whole marked-content
stack before every operator, so deeply nested unmarked BMC blocks made a page
quadratic. The text stack now holds each level's effective id (its own, else
the enclosing one's), and the bounds pass counts the open sequences that
match.
2026-10-22 14:15:50 -04:00
aiongg 735fb8e661 docs(filters, model): record the filter chain and inherited copy limits 2026-10-22 17:47:29 +00:00
aiongg 3a11f39ef0 chore(cos): allow the boxed dictionary key index under clippy
The index is boxed so that it adds one pointer to every Dict, and so
to every Object, rather than an inline map.
2026-10-22 17:47:29 +00:00
aiongg 24701fe959 fix(print): list the source pages once per imposition
page_form listed every page of the document to take the one it
printed, so imposing P pages walked the page tree and copied its leaf
dictionaries P times over: P^2 page copies. impose passes the list it
already holds. A page index outside it is an error, not a panic.
2026-10-22 17:43:04 +00:00
aiongg a0e82da864 fix(render): bound the replacement text copied into redraws
Each unmarked redraw of an /ActualText span's glyphs took its own copy
of the replacement text. The work budget counts comparisons, not
bytes, so a long replacement around one glyph followed by many redraws
at the same place copied it once per redraw: gigabytes from a small
page. Redraws on one page share 4 MiB of replacement text; past it a
redraw keeps its own glyphs, as it does past the work budget.
2026-10-22 17:43:04 +00:00
aiongg 6905acad2c fix(model): bound the inherited values a page walk copies
Each leaf page gets its own copy of every attribute it inherits, so a
large direct /Resources on the root of a page tree with thousands of
leaves was copied thousands of times: a compressed file of a few
kilobytes could need tens of gigabytes. The walk weighs each inherited
value once where it is declared and charges every copy to a 1 GiB
budget; past it the walk is cut off, as an over-large tree already is.
2026-10-22 17:43:04 +00:00
aiongg 1127a66031 fix(crypt): resolve crypt filter methods once per handler
Every string and stream decrypted searched the document's /CF filters
for the method by name. A file controls both the number of filters and
the number of strings, so the search multiplied the two when the
filter in use was listed last. The handler resolves the string, stream
and embedded-file methods when it is built, and looks named filters up
in a map.
2026-10-22 17:43:04 +00:00
aiongg 196be22b64 fix(cos): read a hybrid cross-reference stream once per chain
The materialized cross-reference reader reread a section's /XRefStm
for every revision that named it, decoding the stream and inserting
its rows each time, although the first read had already merged every
entry. Up to 10,000 revisions naming one large stream repeated that
work 10,000 times. A stream already read is skipped, with one repair
log line.
2026-10-22 17:43:04 +00:00
aiongg b122455705 fix(cos): find endstream once per document, not once per stream
A stream whose /Length is wrong or missing is ended at the next
endstream, which the parser found by searching the rest of the file.
Many such streams before one late endstream (a damaged file being
reconstructed reads every one) cost the file size per stream. The
document lists where endstream occurs the first time it needs it, and
each stream looks its end up there.
2026-10-22 17:43:04 +00:00
aiongg 73d15cd8b2 fix(cos): count strings at every depth against the object caches
The object caches weigh what each cached object owns, but the walk
stopped four levels down and counted anything deeper as nothing. Many
objects holding a large string inside a few nested arrays stayed cached
far past the byte budget (34 MiB against 32 MiB in the test, and without
limit as objects grow). The walk now goes past the parser's nesting
limit; anything deeper counts as too large to keep.
2026-10-22 17:43:04 +00:00
aiongg 92e713feb1 fix(cos): a /Filter that is not a name does not decode as unfiltered
Stream::filters reads only a direct name or array of names, so an
indirect /Filter (or an array holding a reference) gave an empty chain
and every decode returned the encoded bytes as if they were the data:
a strict decode reported success on content it never decoded, while a
viewer resolving the reference shows something else. Decoding such a
stream is an error, so callers keep it encoded or refuse it.
2026-10-22 17:43:03 +00:00
aiongg e2c71b60e5 fix(filters): refuse a filter chain longer than eight
Every stage of a stream's filter chain could produce up to the output
limit and nothing bounded the number of stages, so a long /Filter array
over data that decodes to itself (FF FF under RunLengthDecode) repeated
the full work once per array entry. Chains of more than eight filters
are unsupported, like an image codec, and stay encoded.
2026-10-22 17:43:03 +00:00
aiongg 07aea983d0 fix(content): splice copies only the pieces a range touches
Pieces::splice copied the unedited bytes between edits by visiting
every piece for every edit, so a page of many content pieces and many
replaced operators cost pieces x edits steps. The copy starts at the
piece holding its first byte and stops past its last one.
2026-10-22 17:43:03 +00:00
aiongg 3c64f9314e fix(content): skip a long unreadable number whole
When an operand failed to parse, the content parser went back to the
byte after its start and tried again, so a run of n bytes that never
reads as a number (a long run of '+', or digits ending in a letter) was
read again from every one of its bytes: n^2/2 bytes from a tiny
compressed stream. A failed number token longer than 64 bytes is now
skipped whole; shorter ones still recover byte by byte.
2026-10-22 17:43:03 +00:00
aiongg 58dc10c227 fix(cos): read a damaged real's numeric prefix in one pass
To read the longest valid prefix of a token like 1.2.3 the parser
retried the conversion on each shorter prefix, so a long digit run
followed by long junk was converted once per junk byte: quadratic in
the token. The prefix is found in one scan and converted once. A
prefix that spells infinity or NaN no longer reads as a number.
2026-10-22 17:43:03 +00:00
aiongg a374d16515 fix(cos): index the keys of a large dictionary
Dict::set searched every existing entry before inserting a key, so a
parsed dictionary of n distinct keys cost n^2/2 key comparisons: a
few megabytes of keys stalled parsing for minutes. From 32 entries a
dictionary keeps a key index, so each insert and lookup is one hash
probe. Equality and debug output still compare and show the entries
alone.
2026-10-22 17:43:03 +00:00
aiongg 241c5512d6 fix(redact): charge repeated name-tree kids to the node budget
The name-tree count cloned each node's /Kids array and skipped repeated
references without charging the budget, so a node listing the same kid
many times cost work proportional to every entry while the budget saw one
step. The walk now borrows the array, charges every entry it does not
descend into, and stops once the budget is spent.
2026-10-22 13:28:40 -04:00
aiongg 4d64ca8e2d docs(redact): say which objects hidden-information removal reaches 2026-10-22 13:28:40 -04:00
aiongg 94563649e6 fix(redact): name the owner sweep's holder list for clippy 2026-10-22 13:28:40 -04:00
aiongg 822179ccf2 fix(redact): remove hidden data from every object that owns it
Metadata, private application data, associated files and additional
actions were removed from the catalog and pages only. The same entries on
an image or font (XMP), a Form XObject (PieceInfo), a page or annotation
(/AF with its embedded file) or a form field (/AA scripts) survived the
full rewrite, and the scan did not count them, so removing only such a
category reported nothing to remove. A walk of every object reachable from
the catalog now counts and strips these entries wherever they are, leaving
name maps such as resource dictionaries alone (their keys are names a
producer chose), and refuses a graph nested too deeply to walk in full.
2026-10-22 13:28:40 -04:00
aiongg 750bbe27f1 fix(redact): clean the structure tree after removing hidden content
Removing hidden text or hidden layers rewrote page and Form content but
left the structure tree alone, unlike applying redactions. An element's
ActualText, Alt or expansion text still described the removed content,
and a marked-content reference into a Form kept the original Form, with
the removed text, in the full rewrite. The hidden-content pass now cleans
the tags of what each page no longer shows and moves Form references to
the rewritten copy, refusing when the tree can't be cleaned completely.
2026-10-22 13:28:40 -04:00
aiongg 3fe9fe218e fix(redact): remove the widgets flattening keeps when removing form fields
Removing form fields flattens them and then drops the interactive form,
but flattening keeps hidden and no-view widgets (they draw nothing) and
widgets with no appearance. The pages still listed them, so their values
and their parent fields' values survived the full rewrite. Every widget
left after flattening now leaves the pages and the structure tree, inline
annotations included.
2026-10-22 13:28:40 -04:00
aiongg ee010fbbe5 fix(redact): read content strictly before sanitizing or redacting it
Page and Form content was decoded tolerantly, keeping what came before a
corrupt spot and dropping the rest without saying so. A page whose content
broke at its first bytes therefore read as empty: the hidden-text scan
counted nothing, the removal was skipped and the original stream, which
other viewers still draw, stayed in the file. Content is now decoded
strictly; an unreadable page counts as something to remove, so the removal
runs and refuses the page, and a Form read only in part is treated like an
undecodable one.
2026-10-22 13:28:40 -04:00
aiongg f1c56ef865 fix(redact): count a name tree's shared nodes once
The hidden-information count followed every /Kids entry of the attachment
and document-script name trees with only a depth limit, so a node listed
twice by each level was read once per path: a few hundred bytes made the
scan take 2^32 steps. Indirect nodes are now read once each, under a total
node budget.
2026-10-22 13:28:40 -04:00
aiongg 256097023c fix(xfdf): use an imported redaction mark's /Rect only when it is four finite numbers
The /Rect fallback dropped non-numbers before checking the length, so a
malformed /Rect such as [0 0 /Bad 100 100] read as a valid rectangle and
gave the mark quads. The array itself must now hold exactly four finite
numbers.
2026-10-22 13:27:52 -04:00
aiongg 63f7932d06 fix(xfdf): fall back to the /Rect when an imported redaction mark has a zero-area quad
A quad whose corners all lie on one line (or on one point) encloses
nothing, yet such /QuadPoints counted as drawable, replaced the mark's
/Rect and left an imported redaction that covers nothing. Any quad without
area now makes the mark's quads unusable, so its /Rect is used instead.
2026-10-22 13:27:52 -04:00
aiongg 8a8dab6c00 fix(xfdf): keep merged spreadsheet cells from starting a formula
Merge Data Files into Spreadsheet writes field names and values from other
people's files as CSV cells, escaping only the delimiters. A cell starting
with =, +, - or @ is evaluated as a formula when the CSV is opened in a
spreadsheet program. Such cells, after any leading whitespace, are now
written with a leading apostrophe; finite numbers such as -12.5 are left
as they are.
2026-10-22 13:27:52 -04:00
aiongg d266222339 fix(xfdf): draw every imported redaction mark's review outline, opaque
A redaction mark's outline is drawn from its /QuadPoints, but applying the
mark falls back to its /Rect when it has none. An imported mark with only a
/Rect, or with quads the outline can't draw, therefore had no visible
outline while still removing content when applied, and an imported opacity
of 0 hid the outline too. Imported marks now get quads from their /Rect
when their own are unusable (or none, so they remove nothing), and their
/CA and /ca are dropped.
2026-10-22 13:27:52 -04:00
aiongg f0e3880c35 fix(xfdf): never let a same-named imported comment replace a form widget
Comment import replaces an existing annotation with the same /NM in place.
The check for annotations it must keep covered locked comments and
redaction marks but not widgets or field dictionaries, so an imported
comment could overwrite a field's widget object, setting its value and
validation entries without the form value setter while the AcroForm field
list still pointed at it. Widgets and field dictionaries are now kept, and
the imported comment is added beside them under no name.
2026-10-22 13:27:52 -04:00
aiongg 78a562bc08 fix(optimize): bound a merged fragment image's height before sizing its buffer
The fragment merge multiplied the merged width by a height cast from a
float without checking either fit. A tiny fragment rectangle gives a huge
height: the multiplication overflowed (a panic in debug builds, a wrap past
the pixel limit in release builds) and the u32 cast saturated, sizing an
enormous allocation. The height must now fit u32 before the pixel limit is
checked, and the buffer size uses checked multiplication.
2026-10-22 13:27:52 -04:00