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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
Review on #4111: Delete cleared the selection before the edit ran, so a delete the engine
refuses (text used as a clipping path) left the document as it was but dropped what the user
had selected. The selection now stays until the edit applies; `ObjectSelection::prepare`
already clears it when the document changes and keeps it when an edit is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.