Commit Graph
2339 Commits
Author SHA1 Message Date
aiongg aa1c13f71c fix(engine): list a page truncated for both forms and pictures once 2026-10-22 20:56:02 +00:00
aiongg 6394779b81 fix(annot): check a whole link batch before creating any link object 2026-10-22 20:51:55 +00:00
aiongg 923e499597 Merge upstream/main into fix/coderabbit-c5-forms-misc
Conflict in crates/content/src/lib.rs: main ends inline images where the
renderer's tokenizer ends them and counts the images whose end is a guess
(Parsed::ambiguous_images); this branch reads operators through the
streaming Ops iterator that parse collects. The iterator's BI branch takes
main's inline_image result and keeps the ambiguous-image count, which parse
copies into Parsed and Ops::ambiguous_images reports.
2026-10-22 20:51:14 +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 8adf8e2d3b fix(xfdf): read an indirect /F when deciding a comment is locked against import 2026-10-22 18:02:33 +00:00
aiongg c2e96de0b2 fix(annot): extend each page's annotation list once when adding many links
add_many added links one at a time, and each addition walked the page tree
and copied and rewrote the page's whole /Annots array, so a batch of links
cost quadratic time. The pages are found once and each page's new links are
appended in one update.
2026-10-22 18:02:33 +00:00
aiongg 05d09de1e3 fix(annot): read a shared page tree Kids array once
The page walk pushed a node's /Kids array for every new node, so many page
tree nodes sharing one indirect /Kids array re-read it for each node:
quadratic work while the seen check skipped every entry. A shared array
object is read under the first node that lists it, and the walk stops
after four million /Kids entries.
2026-10-22 18:02:33 +00:00
aiongg 240d051b4e fix(measure): read viewports within bounds when finding a scale
scale_at converted every number of a viewport's /BBox before checking it
had four, and read every viewport the page listed, so many viewports
sharing one long box array took time proportional to their product. A box
of any other length is skipped unread, and at most 1024 viewports are read,
the most set_scale gives a page.
2026-10-22 18:02:33 +00:00
aiongg b6cb151774 fix(measure): read a stream's XObject resources once when snapping
Every Do operator resolved the stream's /XObject resources again, and
resolving a direct dictionary copies it, so a large direct XObject
dictionary and many short Do operators multiplied the copying. The
dictionary is read on the first Do and kept for the rest of the stream.
2026-10-22 18:02:33 +00:00
aiongg 2d5c5054e2 fix(forms): merge a calculation's read names with a set
calculation_order merged the names a Calculate script reads into the
field's other reads by searching the list so far for each name, so a
script naming many fields took quadratic time before the link limits
applied. The merge looks names up in a set.
2026-10-22 18:02:33 +00:00
aiongg f2f86455dd fix(engine): read long underscore runs in one pass
Word assembly checked whether the whole word so far was underscores for
every glyph it added, so a long run of underscores on one line took
quadratic time. The underscore state of the current word is kept as it
grows.
2026-10-22 18:02:33 +00:00
aiongg 467c8556ec fix(engine): encode each image once for Office export
Office export encoded and kept a separate copy of an image for every place
a page drew it, so a short content stream repeating Do on one large image
multiplied it into gigabytes before the writers' limits applied. Each image
object is now encoded once and its bytes shared (pdfcraft_export::Image
holds an Arc<[u8]>), distinct pictures share a 512 MiB budget whose
overflow lists the page as truncated, and RTF, which leaves pictures out,
prepares none.
2026-10-22 18:02:33 +00:00
aiongg 90982ba9a8 fix(preflight): check the actions an allowed action chains to
Conversion and verification looked only at an action's own /S. An allowed
action (a GoTo, say) whose /Next held a JavaScript or other forbidden
action written directly inside it was kept, and verification never looked
at actions written directly in a catalog /OpenAction or an annotation /A.
Both now follow /Next through the actions written inside an action (an
indirect /Next is an object of its own, checked as one), within a bound,
and conversion removes the whole chain.
2026-10-22 17:57:53 +00:00
aiongg adab150a45 fix(preflight): find annotation pages from one pass over the pages
verify found each annotation's page by copying and searching every page's
/Annots array again for each annotation, so a page with many annotations
cost quadratic time. The annotation-to-page map is built once (the first
page that lists an annotation keeps it, as before).
2026-10-22 17:57:53 +00:00
aiongg 0a30b4e081 fix(preflight): read content colour one operator at a time
content_colour parsed each content stream into a full list of operators
before looking at any of them, so a small compressed stream of millions of
tiny operators became gigabytes of parsed operators in memory. The content
parser's loop is now an iterator (pdfcraft_content::ops), which parse
collects; preflight reads it one operator at a time and stops once gray,
RGB and CMYK are all found.
2026-10-22 17:57:53 +00:00
aiongg ae791d667b fix(sign): a time-stamp response must carry the requested nonce and policy
parse_response compared only the digest algorithm and imprint with the
query, and the token parser dropped the TSTInfo nonce. A genuine token for
the same imprint from another request or under another policy was
accepted. The token keeps its nonce, and a requested nonce or policy must
match.
2026-10-22 17:57:53 +00:00
aiongg b1196c5cbe fix(xfdf): bound what an FDF import copies out of repeated references
The FDF field walker and the comment copier followed every reference again
each time it appeared, so a field tree or comment whose kids or properties
repeat one reference level after level expanded a small file into millions
of rows or values before the depth limit stopped it. Both now skip a
reference back to an object they are inside of, and share one budget per
import of values and text; past it the import is refused.
2026-10-22 17:57:53 +00:00
aiongg 93436d41b0 fix(xfdf): escape paired line-ending names in exported XFDF
The head and tail attributes were written from the annotation's /LE names
without XML escaping, so a name holding a quote or angle bracket could add
attributes or elements, including field values, to the exported file.
2026-10-22 17:57:38 +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