* 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>
crates/cos/src/document.rs: both new accessors, header_offset (#2525) and reconstructed (#2029).
parity/acrobat-features.toml: the malformed-xref entry has both sides' evidence and notes (#2029 full save of a repaired file, #2717 table numbered from 1).
* fix(engine): Save writes a repaired file in full even without edits (#1937)
A damaged file is repaired on open (its cross-reference index rebuilt from
object headers, as Document Properties reports), but Save wrote it back as it
was: `Session::save_bytes` returned the bytes as opened whenever nothing was
edited, and a repair is not an edit. The "repaired" file on disk was still the
damaged one.
`save_bytes` now writes a reconstructed document in full even with no edits.
`Document::reconstructed()` in cos names the condition (no revision chain:
rebuilt, or built from scratch), and `write_incremental` uses it too, so the
writer and the engine share one definition.
Tests: `engine::tests::a_repaired_file_is_rewritten_by_save_even_without_edits`
(table offsets shifted; no table at all) and
`automation::saving_a_repaired_file_puts_the_repair_on_disk` (doc_open,
doc_info, doc_save over the same path, reopen). Both fail with the condition
removed: the saved bytes equal the damaged file. parity: core.repair-broken-files
cites them and notes that a repair still does not count as an unsaved change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* fix(engine): a signed repaired file keeps its bytes on Save without edits (#1937)
Review follow-up: writing a reconstructed file in full on Save puts the
repair on disk, but for a signed file it would also invalidate its
signatures without warning. Save now skips the early return only when the
document is reconstructed and not signed. A regression test signs a
document, appends a `startxref` that points nowhere (the signed bytes stay
intact, the cross-reference data must be rebuilt) and checks that Save
without edits returns those bytes.
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>
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).
* Issue #2367: an object stream whose entry cannot place it is found by its header
qpdf's xref-compressed-in-compressed.pdf marks object stream 69 as itself
compressed inside stream 81, which the format forbids and which does not
exist. objstm() loaded the container through its cross-reference entry only,
so every object inside (the outlines, the page labels) read as null and a
full save wrote `/Outlines null /PageLabels null`, while an incremental save
(which copies the original bytes) kept them, and MuPDF recovers them.
The container is now found by its own `N G obj` header when its entry cannot
load it (scan_for, the same last resort a plain object with a wrong offset has).
Regression test: document::tests::an_object_stream_marked_as_compressed_is_found_by_its_header.
Verified on the fixture: objects 70 and 82 load (Count 6; 12 page-label
ranges) and a full save keeps both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
The fallback is for file entries only: once the stream is freed or replaced in the overlay,
its objects must not come back from the file's copy (sign's
object_stream_changes_cannot_reuse_old_candidate_membership caught this after the rebase);
a cos test covers both cases.
* cos: a container the saved table marks free is not found by its header either
Freeing an object stream, saving incrementally and reopening left the
overlay empty and the table saying the stream is free; the header
fallback then rescanned the file and the stream's objects came back on
the next full save. The fallback now also stops at an on-disk free
entry.
* M1: put the #2367 test's description on the test, not its fixture
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The engine caches a page's text blocks for opening a paragraph and
applying its edit, but only while the document is unmodified: it was
keyed by the source identity, which edits keep. After the first edit the
cache never hit again, and the page was grouped again on every call (the
inline editor and the panel each call it after an apply).
pdfcraft_cos::Document::read_state() returns what the document reads as:
its source identity, its trailer and every changed object, compared as
allocations that the state keeps alive. A clone or a snapshot restored by
undo has the same state; any set, add, free or trailer change makes a
different one. The cache is keyed by it, so it hits after edits and never
returns another state's blocks.
On a page of 120 rows, text_blocks after an edit took 0.215 ms per call
and now 0.006 ms (release build, best of three; a throwaway timing test).
Tests: a read state matches a clone and not a document changed by set,
free or a trailer change, nor another document; after an edit the engine
groups the page once and undo and redo get their own blocks. The engine
test fails without this change.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Some producers head a classic xref table "1 n" and then write object 0's
free entry first, so every row is one number off and object 1 reads as
free. The renderer coped, but cos did not: printing a scanned invoice
built from cos dropped object 1 (page 1's image) and printed the page
blank. Such a subsection is now read from 0 when its second row locates
an object that is numbered 1, in both xref readers, and the repair is
logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
ROADMAP.md: next's revision history plus this PR's row; the #2267 refresh progress line dropped
docs/roadmap.md: next's revision history plus this PR's row; refresh wording dropped from the status line and the M1 row
crates/cos/src/document.rs: union with #2525: header_relative revisions, the renumbering log line, and log passed to read_section on the first try and the header-relative retry
qpdf counts every offset from the header in a file with bytes before it, so
it already reports the input of
`incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so`
as damaged ("xref not found") and reconstructs it, before anything is
appended. The qpdf check there could never pass; the test's own reader checks
stay. The header-relative test keeps its qpdf check, which passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some producers head a classic xref table "1 n" and then write object 0's
free entry first, so every row is one number off and object 1 reads as
free. The renderer coped, but cos did not: printing a scanned invoice
built from cos dropped object 1 (page 1's image) and printed the page
blank. Such a subsection is now read from 0 when its second row locates
an object that is numbered 1, in both xref readers, and the repair is
logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Some files carry bytes before `%PDF-`, and their offsets count from the header rather than from
the first byte. We read those, retrying a section relative to the header when the absolute
offset misses, but the incremental writer then appended a section whose offsets and `startxref`
were absolute while its `/Prev` kept the header-relative value it was given. qpdf reported "xref
not found" and reconstructed the file, so a save that reported success left a damaged-looking
update behind (#2364, found with KillerPDF's reserve3.pdf and a Subject edit).
The reader now records on each `Revision` whether it was found counting from the header, and the
writer counts the new section the same way as the one its `/Prev` points to. A file whose
offsets already include the prefix keeps absolute offsets, which the second test pins so the fix
cannot overcorrect.
Seen red with the writer change reverted: the appended section's `startxref` missed its `xref`.
Fixesstorytold/pdfcraft#2364
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsT1y8xBvkXeBxEUQqdDtj
* M1: edit finds the pages a damaged file's readers find (fixes#1656)
* M1: rebuild a lost page tree only when the reader shows the same pages, and refuse edits to signed rebuilt files (#1656)
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M7: share Windows local-link fixture portability fixes (#1265)
Use the identical test-only prerequisite already proposed in #1263: Unix-only mutable vectors, ordinary Windows drive paths and escaped PDF literal strings. This is shared CI support for the separate xref overflow fix; production link handling is unchanged.
Generated with Codex.
* M1: return an error for exhausted xref object numbers (#1337)
Check the next-object addition and return CosError at the xref offset rather than overflowing or wrapping to object zero. Classic-table and stream regressions reproduced the original panic; ordinary, zero-offset and repaired-range controls preserve catalog access and allocation.
Validation: 2732 passed, 0 failed, 13 ignored; strict all-target Clippy, fmt, 31 WASM crates, layering, assets, deny and parity passed. The rebuilt CLI opened and rendered a clean synthetic page.
Generated with Codex.
* M1: place overflow notes beside stable milestone entries (#1337)
Keep the tested code unchanged while avoiding progress-note conflicts with newly landed main updates. Formatting and diff checks passed.
Generated with Codex.
* Merge main: adapt exhausted-xref repair to the compact xref index
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M1: keep unknown trailer entries on a full save (#1338)
A full rewrite built a new trailer from a few known keys, so accepted custom metadata disappeared without a repair note. Copy every other entry and write objects that only those entries reference.
* Run a tool after a background open of the file it asked for
A tool chosen with no document opened the picked file on the worker, but only ran when that open finished inside the picker callback. Remember the command on the pending open, and let the command test wait for the worker.
* Wait for background opens in the command tests
Opening a picked file or a recent file finishes on the worker, so a fixed handful of frames can assert before the tab exists.
* Compare batch copies to the PDF that was written
A new text PDF stamps the current second, so a later pdf() call is a different file. The numbered copy and the source must match the bytes that were saved.
* Drop the unused batch fixture left by the merge
Main already keeps the written PDF in original, so the older source binding is unread and fails -D warnings.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
To classify what later revisions changed, the signed bytes were reopened
with Document::open, without a password, so for a file opened with a
nonempty user password the earlier revision was unreadable and the earlier
signature became Invalid as soon as it was counter-signed. Document::
open_with_same_credentials opens such bytes with the current document's own
security handler when their /Encrypt and /ID are the same (no password is
needed again or kept), and the signature validator uses it.
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>
encrypt_dict multiplied any V4 StdCF /Length up to 32 by 8, so a large
negative value overflowed (a panic with overflow checks, a wrapped length
without). A zero or negative length is meaningless; read it as if absent, the
128-bit default, which also fixes small negatives that used to clamp to a
40-bit key and fail to open.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M4: keep the last saved revision across undo (#1310, #1311, #1312)
Undo treated a return to the opened file as unsaved work, and after a save it followed the restored snapshot's older base. Revert and the next save now stay on the last saved file, so that revision remains in the PDF.
* Run a tool after a background open of the file it asked for
A tool chosen with no document opened the picked file on the worker, but only ran when that open finished inside the picker callback. Remember the command on the pending open, and let the command test wait for the worker.
* Wait for background opens in the command tests
Opening a picked file or a recent file finishes on the worker, so a fixed handful of frames can assert before the tab exists.
* Compare batch copies to the PDF that was written
A new text PDF stamps the current second, so a later pdf() call is a different file. The numbered copy and the source must match the bytes that were saved.
* Drop the unused batch fixture left by the merge
Main already keeps the written PDF in original, so the older source binding is unread and fails -D warnings.
55 files hand-rolled the same serialiser -- number the objects, record each one's offset, emit a
classic cross-reference table and a trailer -- 111 times over. The object bodies are properly the
business of the crate under test; the envelope around them is not, and every copy is a place for a
fixture to be quietly wrong in a way that makes a test prove less than it appears to.
`pdfcraft-testkit` holds that envelope. It has no workspace dependencies on purpose, so the crates
at the bottom of the layering can use it without a cycle, and it returns bytes rather than a parsed
document for the same reason.
The builder is byte-identical to the serialiser it replaces, asserted against a verbatim copy of
the old code across versions and trailer variations, because a migration that changes a fixture's
bytes silently changes what the test using it proves. Watched failing by breaking the free-entry
generation: three tests caught it. The one intended difference is that an empty stream dictionary
gives `<< /Length N >>` rather than a doubled space.
Migrates cos, measure and redact: 111 copies in 55 files becomes 108 in 53. The rest are
follow-ups rather than one sweep -- ui-egui alone is 27 of those files and is the most contested
area in the repository. Some copies should stay: a test about a malformed cross-reference table
needs to write its own bytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every test that wanted an external oracle probed for it by hand and chose for itself what to do
when it was missing. The common choice -- print a line and return -- reports as a pass, so a suite
with no oracles installed looked exactly like one in which every oracle agreed.
That was not hypothetical. No CI runner installed qpdf, so `qpdf_agrees` had never executed on a
runner, while README.md said saved output was "independently checked with qpdf". For R2-R4 that
test is the only check of owner-password authentication (Algorithm 7) that does not go through our
own code.
`pdfcraft-oracle` holds the probes and one rule: a missing oracle is a declared skip or a failure,
never a silent pass. `PDFCRAFT_ORACLES_REQUIRED` picks which, on the same convention as
`CRAFT_FONTS_REQUIRED`. CI installs qpdf and sets it, so a skip notice there is now a red build.
The crate has no panicking path of its own; `or_skip!` expands its assert into the caller, which
is test code.
Verified by running the qpdf tests against qpdf 12.4.2 for the first time -- they pass, so the
README claim is now both enforced and true. Both gate directions were watched: absent and
unrequired skips with a notice, absent and required fails with the install command in the message.
Only qpdf is installed in CI, because it is the only tool the test job uses. The Poppler pixel
comparison in crates/organize stays ignored until a change turns it on and verifies it there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* M8: redaction unlinks removed images and forms from inherited and shared resources
Apply Redactions and Remove Hidden Information give a rewritten page its own
pruned /Resources, but the dictionary the page drew through before could still
name the image or form that was removed:
- a /Resources inherited from a /Pages node;
- one resource dictionary object shared by several pages;
- the page's own dictionary, when a form without /Resources drew the image
through it.
A full-rewrite save keeps whatever is still referenced, so the removed image
samples and form text stayed in the saved file while the operation reported
success. Per-page resources (the case fixed in #1099) were not affected.
The interpreter now records the original object behind every XObject it drops
or replaces. After the page loop, each page-tree resource dictionary that still
names one loses that entry unless a page drawing through that dictionary still
draws the name (the same image on another page, or again outside a mark). A
dictionary that loses an entry is replaced by a private copy on its node, so a
dictionary object shared with other nodes is left alone for them. If a page's
use of a name cannot be told (unreadable content, a form that will not decode),
the operation fails closed.
Tests: redacted_xobjects_do_not_survive_through_inherited_or_shared_resources,
an_image_another_page_still_draws_through_the_same_resources_is_kept,
an_image_a_form_drew_through_the_pages_resources_does_not_survive,
a_hidden_layers_image_does_not_survive_through_inherited_resources.
Not covered here: originals named by a form XObject's own /Resources, by an
annotation appearance or by a pattern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M9: a cross-reference entry the reader sets aside is never an unchanged signed object
The reader that judges post-signing changes and the renderer that draws the
page resolve some cross-reference entries differently. A revision appended
after signing could redefine a page's content stream and give the object an
entry the reader sets aside while the renderer follows it:
- an in-use entry at offset 0 (the reader skips it; the renderer fails to read
the object there, rebuilds its index and takes the last definition);
- a second entry for the same object in one section (the reader keeps the
first, the renderer the last).
The classifier compared the signed object with itself, found nothing changed
and reported the signature Valid while the page showed the new content.
pdfcraft-cos now records the entries it does not take at face value
(Document::xref_oddities: offset-0 in-use entries other than object 0,
duplicate entries that differ, reserved cross-reference stream types, and a
hybrid /XRefStm entry that disagrees with the table), and can say whether an
entry locates the object itself (Document::xref_entry_is_exact). What it
resolves is unchanged.
classify_changes treats any such entry added after the signed revision as a
change that is never permitted (page content when the object was a signed page
or its content, other changes otherwise). An object whose content compares
equal at a new place is unchanged only when its entry is exact: where the
reader had to search for the object, another reader may arrive elsewhere.
Tests: a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content,
a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object,
cross_reference_entries_that_are_set_aside_are_recorded,
an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact.
This closes the two demonstrated triggers and their near variants. It does not
prove that the two parsers agree on every file: other differences in what each
tolerates may remain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M8: Apply Redactions is refused when the page still shows text under a mark
Redaction decides what lies under a mark from the positions its own
interpreter computes, and its verify pass reuses them. Where those differ from
the renderer's, a mark drawn over the text as displayed lies over glyphs the
interpreter places elsewhere, or does not see at all: they stayed in the file
and the operation reported success.
The case the test pins: a font and size set through an ExtGState /Font entry
(`gs`), which the renderer honours and the interpreter does not model. The same
kind of gap was observed for a font a form XObject takes from its caller, for
some predefined CMaps and for a rotated Type 3 font matrix. The standard-14
width error that first showed it is fixed at its root by d0d1ad81.
The engine now asks the renderer, which is what the user saw when placing the
marks. After redaction runs, the result is written as a save would write it
(without the marks' own overlay), its text is extracted, and a glyph still at
least half inside a mark fails the edit with the existing "could not be
verified" error. The edit runs on a copy, so the document is unchanged. A page
whose text cannot be read back fails the same way.
This makes such mismatches fail closed; it does not make those documents
redactable. Teaching the interpreter each of them is separate work.
Test: redaction_is_refused_when_the_page_still_shows_text_under_a_mark (the
line whose font is set by `gs` is refused and left unchanged; the same line set
with `Tf` is redacted).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
An unchanged classic-table file used to append xref immediately followed by trailer, which qpdf treats as a damaged file. A zero-length subsection keeps trailer-only updates without rewriting any object entry.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The PDF/A device-colour scan split content streams only on spaces and
line feeds, so `0 0 0 1 k` written with carriage returns, tabs, form
feeds or NULs between the tokens went unseen: verify reported no
DeviceCMYK, and convert gave the CMYK page an sRGB output intent.
Split on all six whitespace characters of ISO 32000-2 §7.2.3, using the
lexer's own `is_whitespace` (now exported from pdfcraft-cos) instead of
a second list.
Adds tests::device_colour_is_found_after_any_whitespace, which fails
before this change; its gray and RGB controls check that those pages
still get the sRGB output intent.
Both page-label readers decode each range's prefix into a string of its
own: render's inspection for its range table, Organize for the ranges it
returns. Ranges may share one prefix object, so a small file could ask
for a copy per range: within the 200,000-entry tree limit, about 200 MiB
of copies of one 1,024-byte prefix. Decoded prefixes now take at most
4 MiB in all (MAX_PREFIX_TOTAL_BYTES, beside MAX_LABEL_TOTAL_BYTES). Past
that the tree is refused before any label is made, as the other tree
limits refuse it.
Tests: 5,000 ranges sharing a 1,000-byte prefix are refused in both
readers (relabelling such a tree changes nothing), and exactly 4 MiB of
prefixes is still read.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Encryption derives its keys from a prepared password, not the typed one:
AES-256 (R6) applies SASLprep, falls back to the password as given if
SASLprep refuses it, and truncates the UTF-8 to 127 bytes; RC4/AES-128
(R2-R4) use PDFDocEncoding cut or padded to exactly 32 bytes, and the
padding starts with "(". Protection::validate checked only the typed
strings, so a permissions password of U+00AD wrote a file that opens with
owner rights and no password, and an open password "A" with a permissions
password U+FF21 (or RC4/AES-128 passwords "a"x31 and "a"x31 + "(") wrote a
file whose open password lifts the restrictions.
pdfcraft-crypt gains effective_password(algorithm, password), the bytes the
keys are derived from (it and create share the private prepared_password
helper), re-exported from pdfcraft-cos. validate refuses a password whose
effective bytes are those of "", and an open and a permissions password
whose effective bytes are equal. Opening files is unchanged.
Fixes#1185.
A PDF protected with only a permissions (owner) password opens without
asking, restricted, and nothing let the user enter that password
afterwards: the security notice only led to a read-only Security tab and
the hint said to reopen the file with the password, which the app had no
way to do (#995, #1077).
Now the security notice and Document Properties > Security offer Unlock...,
and Change settings / Remove security on a restricted document ask for the
permissions password first and then carry on, as Acrobat does. The open
password is refused there.
- crypt: SecurityHandler::authenticate_owner accepts only the owner
password and refuses a different file key.
- cos: Document::unlock upgrades the open document in place; owner and user
passwords unlock the same file key, so nothing is decoded again.
- engine: Session::unlock (not an edit: no undo step, not marked unsaved)
also unlocks the undo and redo states and reopens saves as owner;
Document::can_unlock, EditError::WrongPassword, command protect.unlock.
- ui: the permissions password prompt shares the open prompt's modal; the
notice and Security tab get Unlock...; the disabled-command hints no
longer point at reopening the file.
- i18n: the new strings in all 15 full catalogs (catalog hashes updated).
Tests cover crypt, cos and engine (all four algorithms, wrong and open
passwords, undo, redo, save, removing security afterwards) and the UI
(notice flow, Remove security asking first).
Closes#995Closes#1077
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Cap editing and text-layer caches that grew without bound
The cos editing cache kept every touched object until save, so editing
a large document pinned its whole object graph. Bound it like the scan
reader: 1024 objects / 32 MB of owned bytes, 256 object streams /
32 MB, evicting to the newcomer. Returned objects outlive eviction, and
clones share the caches, so this only costs re-parsing.
The viewer text cache had the same shape: every visited page's text
stayed for the session. Retain visible, selected and matched pages plus
a 24-page prefetch window each frame; evicted pages re-extract on
revisit. Skipped while a search is active, where demand still wants
every page. Failures are never evicted: they cost nothing to remember.
cli info on a 1024-page/147 KB file (debug build, same host/command):
before 66.7 MB RSS, after 54.0 MB RSS (3 runs, tight).
Tests: editor count bound, byte bound, snapshot-clone reads (cos);
evict-far/keep-visible+matched+selection, search-active keep-all
(canvas). All mutation-checked: each bound goes red when disabled.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* Index form fields once per refresh instead of once per field
The form-scope refresh ran a linear find over all fields for every
field, O(F^2) on each keystroke; forms reach 10k fields. Build one
name index up front, O(F). entry().or_insert() keeps the old
first-match semantics for repeated field names.
New test with two same-named widgets pins first-wins and goes red
under insert. 141 engine tests green, clippy warnings and perf lints
clean.
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
---------
Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
* cos: decode only the first filter of a stream, strictly
Stream::decoded_first_filter_strict decodes the outer filter of a chain and leaves the rest of the chain encoded, within a caller bound. The optimizer uses it to remove an ASCII85 or ASCIIHex wrapper without inflating the inner data.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* optimize: strip redundant ASCII wrappers from filtered streams
A stream whose filter array starts with ASCII85Decode or ASCIIHexDecode loses that outer layer when the serialized replacement is smaller. The inner encoded bytes are kept exactly, and the decoded stream is unchanged. Streams with DecodeParms, image, object stream, or Crypt filters are left alone, as are streams whose wrapper fails strict decoding.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* optimize: document redundant ASCII-wrapper cleanup
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Revisions 2–4 of the standard security handler take the password in
PDFDocEncoding (ISO 32000-2 §7.6.4.3.2, Algorithm 2). PdfCraft authenticates
an encrypted file twice, and neither did that:
- pdfcraft-crypt took Latin-1 and dropped every other character, so "ı"
(0x9A) and "€" (0xA0) vanished and "ılık€" became "lk";
- the renderer (vendored hayro-syntax, "TODO: Convert to PDFDocEncoding")
used the UTF-8 bytes, so any non-ASCII password failed, even "Çok güzel",
which Latin-1 and PDFDocEncoding write alike;
- the engine handed the renderer an owner-recovered user password as Latin-1
characters, which the renderer then read as UTF-8 again.
Files that qpdf or Acrobat protected with such passwords could not be
opened with the right password.
- pdfcraft-crypt encodes R2–R4 passwords in PDFDocEncoding and, for a
password with characters PDFDocEncoding lacks (ğ, ş, İ), also tries its
UTF-8 bytes, which qpdf writes for those. The PDFDocEncoding table moves
here from pdfcraft-cos (which depends on this crate) and cos uses it.
- hayro-syntax (vendored patch 14) tries an R2–R4 password as given, then
in PDFDocEncoding.
- The engine passes a recovered user password to the renderer as UTF-8 when
its bytes are UTF-8 and as PDFDocEncoding otherwise, so the renderer gets
exactly those bytes back.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Acrobat reports an encrypted PDF as "damaged and could not be repaired", before
asking for the password, when the document catalog sits in an object stream.
Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such
files, and qpdf keeps the catalog standalone too. Full saves of encrypted
documents now write the catalog as its own object; unencrypted output is
unchanged. Verified with Acrobat DC on macOS: the same file fails with the
catalog packed and opens with it standalone, in AES-256 and RC4-128.
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu
Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* M9: sign and timestamp encrypted documents
Signing a password-protected PDF failed with "not supported: signing
encrypted documents". The writer encrypted every string, including the
signature's /Contents placeholder, which the signer patches in place
after hashing.
- cos: a signature dictionary's /Contents is never encrypted or
decrypted (ISO 32000-2 §7.6.2), identified by /Type /Sig|/DocTimeStamp
or by /Contents + /ByteRange. Existing signatures in encrypted files
now read their real CMS and survive rewrites of their dictionary.
- sign: the blanket refusal is replaced by the document's permissions
(Table 22) when opened with the user password: existing fields and
document timestamps need fill-forms, new fields need annotate +
modify, certifying needs modify. Refusals name the permissions
password.
- Tests for all four algorithms, counter-signing, tampering,
permissions, document timestamps and the sign_document tool; parity
entry sign.encrypted-documents; ROADMAP log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
* M9: signing an encrypted document waits for a pending security change to be saved
Otherwise the incremental save becomes a full rewrite that applies or removes protection while
signing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A PDF whose AcroForm /Fields array is empty or missing listed no
fields, so none were fillable even though every widget sat on a page
with its name and value: Acrobat and the browsers fill those. The page
widgets are now adopted as fields — after the tree-listed ones, through
each widget's topmost unlisted /Parent so a field split across several
widgets stays one — and the leniency is recorded, never silent.
- forms: fields() walks /Fields as before, then adopts the unlisted
page widgets; adopted_page_fields() counts them for the note.
- render: the inspector's field list adopts them the same way, so the
Fields panel, the field-count notice and menu gating follow.
- engine: opening such a file notes "the form's /Fields list omits N
fields; they were read from the page annotations" in the repair log
(Document Properties ▸ Advanced).
Co-authored-by: GLM-5.3 <noreply@z.ai>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
xtask corpus cloned mozilla/pdf.js at HEAD with no commit pin and no content check, and the corpus tests returned early when the directory was missing. So the corpus sweep measured a moving target, xtask/baselines/pdfjs.json was compared against whatever upstream happened to be that day, and a damaged or tampered checkout produced green results. AGENTS.md §2 asks for fixtures fetched pinned by commit and sha256-verified.
Verification is two layers. The commit fixes which tree we ask for; the manifest digest fixes what landed on disk: one '<sha256> <path>' line per file, sorted by path, then one sha256 over that text. Git object ids are SHA-1, so the manifest is what makes this a sha256 check. The pin is 5d923c9 with 1455 files, which matches what the GitHub tree API reports for test/pdfs.
The checkout sets core.autocrlf=false and core.eol=lf. This repository has autocrlf=true globally, so without that git would rewrite line endings on checkout and the same commit would hash differently on Windows than on Linux; the pin has to mean the same thing everywhere.
A successful verification writes corpus/pdfjs/.pdfcraft-corpus.json. The corpus tests now skip only when nothing is fetched, and fail loudly when a checkout exists without that stamp. xtask check verifies before sweeping, so the baseline is never compared against an unknown tree.
Checked end to end: the unrecorded-pin guard refuses to fetch; --update-pin fetches and prints the constants; appending one byte to one of the 1455 files is detected and repaired by re-fetching; removing the stamp makes the corpus tests fail with a message saying what to run. Against the verified corpus the tests pass: 964 files ok and 7 password cases.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The bookmarks panel decoded /Title through lopdf::decode_text_string,
which keeps a PDF 2.0 UTF-8 BOM as a visible U+FEFF, drops the whole
title when the bytes after a UTF-8 BOM are not valid UTF-8, and reads
BOM-less raw UTF-8 (written by some producers) as PDFDocEncoding
mojibake. Outline editing in pdfcraft-organize used a second decoder,
PdfString::to_text, so the two could disagree.
PdfString::to_text now decodes UTF-8 after a BOM lossily, reads
BOM-less strings that are valid non-ASCII UTF-8 as UTF-8, and strips
stray leading U+FEFF. The inspector's text strings (titles, name-tree
keys, field values) go through the same function.
The reporter's file decodes correctly on main, so this is hardening of
the decoder rather than a reproduction of that file.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* D5: README for cos and engine, and an honest cos status line
Thirteen crates have no README, although the session protocol tells every session to read the README of the crate it is touching. These are the two biggest: cos is where every hostile file lands first, and engine is the whole API surface a frontend gets.
Each states its layer and the rules that bind it, sketches the API against the real signatures, and ends with what is deliberately not done. The cos README spells out the clone-is-a-snapshot property and why both writers return bytes rather than taking a sink; the engine README spells out the four-step editing model that makes what you see what Save writes.
Also corrects the module doc on cos/src/lib.rs, which still said encryption and object-stream output for full saves were not done. Both shipped: security.rs handles R2-R6 with crypt filters and /EFF, and SaveOptions::object_streams defaults to true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* M14: let neutral Windows locales fall through to display language
Co-authored-by: Codex <codex@openai.com>
* pdfcraft-cli --help lists `mcp` only when the build has it
Follow-up to #540: a build without the `mcp` feature (`--no-default-features`) still listed
`pdfcraft-cli mcp` in its usage, and `pdfcraft-cli mcp` then said "unknown command".
USAGE is now put together with `concat!`, and its `mcp` lines come from a macro that is empty
without the feature. In such a build `pdfcraft-cli mcp` says the MCP server was left out instead
of calling it an unknown command.
- tests/help.rs: usage_lists_mcp_only_when_built_with_it (runs in both feature sets) and
mcp_without_the_feature_says_it_was_left_out (without the feature)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* engine README: the execution plan is local-only, so don't link a path that doesn't exist
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Streetblock <github@streetblock.de>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M14: bound large-PDF memory use (#307)
Inspect large documents lazily, share parser/resource metadata, bound full-save
and raster retention, and cache signature candidate discovery safely.
Preserve upstream rendering deduplication, pixel alignment, signature previews,
and organizer insertion behavior, with regression coverage for their interaction.
Validated with 1,336 workspace tests, 193 parser tests, strict workspace Clippy,
30 WASM crate checks, 1x/2x GUI protocols, and same-PDF workflow comparisons.
* cos: drop the writer cache-policy probe and its test-only hooks in production paths
* cos: the temporary reader keeps parsed objects until a count or owned-bytes budget
Since #552 a stream read from the file is a view into the input, so clearing the whole parse
cache after every read saved little memory and cost most of the full-save slowdown (every
object parsed twice, indirect /Length objects re-parsed per stream). Clear only past
OBJECTS entries or STREAM_BYTES of data of the cache's own (decrypted streams, strings).
* cos: the writers refuse a document opened with a stream limit (read-only inspection)
* cos: a full save fails rather than writing null when an object reads differently the second time
* render: parsing is not timed by the watchdog; timing tests with wide margins
A slow first parse (a huge or damaged file) made the whole pool fail every page for its
lifetime. Parsing, shared or private, is now its own untimed stage, as on main before the
shared parser; rendering is timed from when parsing ends. The init-timeout tests become one
deterministic test (a 2 s parse against a 10 ms limit, shared and private). Watchdog tests
use limits and injected delays ~10x apart instead of 25-50 ms against real renders. Also:
saturating retired-worker count, main's stop-flag test runs on private parsers (the first
shared timeout is a private retry), set_queue documents that unlisted results are dropped,
and retire() documents the permanent per-worker-parser fallback.
* render: page numbers in the lazy page tree use checked conversion
* render: the lazy inspector is compared with lopdf on named destinations and object streams too
* ui: on-screen pages always render; pages turned back to stay cached; stable thumbnail tags
- Visible pages and tiles are always admitted and uploaded (the viewport bounds them); the
byte and count limits decide only how much prefetch fits. A HiDPI two-up view or a 5K
viewport left pages on 'Rendering...' for good.
- Pages no longer demanded stay cached, nearest the current page first, while the 128 MiB
allowance and the 24-page count have room: paging back no longer re-renders.
- Thumbnail density follows the demand count rounded up to a power of two, so rows scrolling
in and out of an organize grid don't change every thumbnail's tag and re-render it.
- A suspended (background) tab drains its pool's finished results instead of holding them.
- Organize drag-and-drop ends on the primary button only.
- Tests: the stale-result test uses valid pixels and checks nothing was uploaded; the
organizer wheel test runs 40 steps; regressions for the first three fixes.
* vendor: mark the hayro-syntax lock and SegmentList patches and list them as (8) and (9); clippy 1.99 as_chunks in a raster test
* render: clippy 1.99 — contains over iter().any() in a raster test
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* hayro-syntax: fix float mantissa overflow on high-precision coordinates (#199)
jsPDF outputs double-precision floating-point coordinates with 16+ decimal
places in /MediaBox. In hayro-syntax, read_inner accumulated float mantissa
digits into u64 using wrapping arithmetic. Integer mantissas exceeding
u64::MAX wrapped modulo 2^64, severely corrupting page dimensions
(e.g., 3874.96 pt became 185.61 pt, rendering as a warped ribbon).
Accumulation now uses checked arithmetic: trailing fractional digits
exceeding u64 capacity are safely ignored without incrementing decimal_shift.
This preserves full IEEE-754 f64 precision while preventing overflow for
numbers with arbitrary numbers of decimal places.
Includes:
- Unit test in hayro-syntax with reported values and a 50-digit fraction.
- Synthetic PDF regression test in pdfcraft-render verifying inspect().
- Vendored patch (6) documentation in vendor/README.md.
Fixes#199
* M2: bound page-label work and output before applying labels
Share bounded PDF Roman and repeated-letter formatters between inspection
and editing. Bound tree traversal, raw and decoded prefixes, individual
labels and aggregate label output, with checked numbering arithmetic.
Stage inspection labels before applying them and retain physical page
numbers with a warning when optional labels cannot be processed. Reject
unsupported label edits before changing the document tree.
Add synthetic limit, cycle, Unicode, numbering and atomic-edit regressions.
Co-Authored-By: OpenAI Codex <codex@openai.com>
* Never crash: mesh shadings can't exhaust memory or run for minutes
`cargo xtask fuzz` (with the dash, JBIG2 and decompression fixes in
place) found a page whose Coons patch shading (type 6), a 147-byte
stream with 1-bit coordinates decoded onto +-40000, took the renderer
past 3 GB. hayro-interpret samples mesh shadings (types 4-7) pixel by
pixel over each triangle's device-space bounding box into a map, with
nothing but the u16 cast bounding it: up to 65536 x 65536 entries for
one triangle. A synthetic single patch passed 6 GB in six seconds; ten
thousand triangles that each cover half a 1000 x 1000 page ran for
minutes. Two rounds of review of earlier versions found more: every
patch was cut into 722 triangles (about 150 KB) before anything limited
them; a lattice mesh with `/VerticesPerRow 0` pushed empty rows forever,
and one with 4294967295 read the whole stream into one row; a mesh
painted a thousand times was triangulated a thousand times; and the
first caps cut content without a word.
Vendored hayro-interpret patch (18):
- `ShadingPattern::encode_within(area)`: the vendored hayro renderer
passes the area it will actually draw (the path's bounding box on the
canvas, which it already computes) plus a pixel for texels at a
fractional edge, and triangles are sampled only inside it, so the map
holds at most that area's pixels.
- `TriangleSampler`: at most 8 visits per pixel of that area plus 4 per
triangle in it. A triangle's bounding box is about twice its area and a
sub-pixel triangle's is at most 2 x 2 pixels, so tiling meshes and
small dense ones (a patch in a few pixels is 722 triangles) still
shade completely; overlapping ones stop.
- Coons and tensor-product patches are triangulated one at a time.
- Meshes hold at most `MAX_MESH_PATCHES` (2^16; a 200 x 200 gradient
mesh is 40 thousand) patches and `MAX_MESH_TRIANGLES` (2^20)
triangles, and a lattice needs two (ISO 32000-2 8.7.4.5.6) to 2^19
vertices per row.
- A page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25; the 200 x 200
mesh is 29 million) mesh triangles in all, reset per page like the
existing paint budget.
- Each of these cuts logs a warning.
`encode()` keeps its signature; with no area it samples within a fixed
2^24 visits.
Synthetic regression tests in render:
- `mesh_shadings_far_off_the_page_render`: a Coons patch and a triangle
mesh decoded onto +-40000 render and the rest of the page draws; a
patch on the page, the part of the huge one that covers it, and a
patch filling a 4 x 4 pt rectangle as a pattern are all shaded.
- `many_overlapping_mesh_triangles_finish`: 10,000 triangles each
covering half of a 1000 x 1000 page finish, and are drawn.
- `mesh_shadings_with_many_patches_or_no_rows_finish`: 40,000 patches
beside the page, and a lattice with `/VerticesPerRow 0`.
Red on main (out of memory, killed at 3.9 GB). Each part is pinned by a
mutation: no visit budget stalls past 60 s; no clip leaves the covering
patch unshaded; no per-triangle allowance leaves the small patch
unshaded; triangulating all patches first runs out of memory (killed at
5.8 GB); no rows check runs out of memory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Never crash: decompression bombs can't exhaust the renderer's memory
The renderer's parser (vendored hayro-syntax) decoded Flate, LZW and
RunLength streams with no output limit. A 2 MB page whose content
stream inflates to 2 GB, or whose 8 x 8 image does, took `pdfcraft-cli
render` and `text` past 3 GB in under a second, and a few more MB of
file scale it further. On the desktop that is the first-page render of
an emailed PDF, and the allocation-failure abort takes every open
document with it. Our own layers already bound single streams (lopdf's
load-time decoding at 256 MiB, cos at 1 GiB), so `info` stays at a few
MB; the renderer path didn't. Random mutation almost never makes a
high-ratio stream, so `cargo xtask fuzz` doesn't find this; found by
hand while triaging the fuzz run.
Vendored hayro-syntax patch (6):
- `decode_limit` gives each filter an output limit, and Flate (both
flate2 and the fallback inflater), LZW and RunLength never write past
it (the buffer can still grow to under twice it):
`MAX_DECODED_STREAM`, 256 MiB, lopdf's figure; or, for the last filter
of an image whose size, bits per component and components are known,
twice its pixel bytes (a PNG predictor tag byte per byte at most),
capped at 1 GiB. Earlier filters in a chain produce compressed data
and get the general limit.
- `Stream::decoded_within(max)` for callers that budget.
- A page's `/Contents` array shares one `MAX_DECODED_STREAM` (naming one
bomb twenty times decoded 5 GB), reserving exactly what it appends.
Vendored hayro-interpret patch (18):
- The content a page holds at once (its contents, and the forms, tiling
patterns and Type 3 glyph procedures being painted) stays within
`MAX_PAGE_CONTENT`, 512 MiB, reset per page like the existing paint
budget. Forms nest fifty deep and each level kept its content while
the next ran: a form painting itself around a 256 MiB stream would
have held over 12 GB. Each level gives its bytes back when it is
dropped, so content painted again and again keeps drawing, and a
stream without filters (borrowed from the file) costs nothing.
- Type 0 sampled functions read every sample into a 4-byte value and
then a table entry: 256 MiB of 1-bit samples asked for over 8 GB. They
now take at most 2^21 table points and 2^23 values (the largest real
tables, DeviceN tint transforms such as 33^4 points x 3 outputs, are
1.2 million and 3.6 million), counted with checked arithmetic, and
read no further than `/Size` asks. `/Size 0` no longer underflows
building the default `/Encode`.
Three rounds of independent review found the aggregate cases, buffer
doubling past the limit, a chain/predictor shape the first image limit
would have truncated, the Type 0 amplifier, and a first, cumulative
budget that would have dropped legitimately repeated content, and a
first Type 0 cap low enough to refuse real tint transforms; all fixed
here.
Measured in a release build (render; OS peak commit):
- content stream inflating to 2 GB: 389 MB, 0.3 s
- 8 x 8 image inflating to 2 GB: 4 MB, 0.2 s
- /Contents naming it twenty times: 516 MB, 0.3 s
- a form painting itself around it: 644 MB, 0.4 s
Synthetic regression tests in render (flate2 is a new dev-dependency
there, already in the workspace):
- `decompression_bombs_stop_at_the_stream_limit`: 288 MiB bombs through
flate2, the fallback inflater (a stored block with a wrong length
check, which flate2 refuses and the fallback accepts), LZW and
RunLength each decode exactly 256 MiB, and at most 128 bytes as an
8 x 8 image.
- `page_contents_and_nested_forms_share_one_budget`: three /Contents
references decode one stream's worth; a self-painting form finishes.
- `content_painted_again_and_again_keeps_drawing`: a 2 MiB form painted
300 times draws the 300th time.
- `sampled_functions_with_billions_of_samples_are_refused`.
Ten mutations each turn them red: no limit at all; no limit in flate2,
the fallback, LZW, RunLength or for images; an unshared /Contents limit;
no page budget; no refund on drop; no Type 0 sample cap. (Taking a form
out of the budget no longer compiles: its content is the budget guard.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M2: normalize JPEG2000 samples without overflowing
Use bounded integer sample scaling for 1-32-bit JPX images and reject empty dimensions before chunking. Cover public decoding and continued page rendering with synthetic regressions.
Co-Authored-By: OpenAI Codex <codex@openai.com>
* Never crash: decompression cut at its limit is logged, not silent; vendor README renumbered after #575
---------
Co-authored-by: philkwek <philkwek03@gmail.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: James Locke <336218001+jcl-bit@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M1: stream data read from a file points into the file instead of being copied
Every stream COS loaded was copied out of the document's bytes
(`data[start..end].to_vec()`) and kept in the object cache until the document
closed. Anything that reads every object therefore left a second copy of all
stream data behind. That includes a full save, which edits needing a rewrite
(repaired files, security changes, redaction) run on every change. A full save
of a 208 MB document left 208 MB cached on it.
`Stream::raw` is now `Bytes`: owned bytes, or a range of a larger shared
buffer. Streams parsed from a document's own bytes are ranges of that buffer,
which the document keeps anyway. Decrypted and newly built streams own their
bytes as before. A stream added to a document from another document's file gets
its own copy of just its bytes (`Document::set`/`add`), so it never keeps
another whole file alive.
Results, measured with an exact heap counter:
- A full save of a 208 MB document leaves 0.2 MB cached instead of 208.2 MB.
- The first full save of a freshly opened document is about twice as fast:
10 → 5 ms at 40 MB, 38 → 21 ms at 200 MB.
Output is identical: 68 incremental and full saves of five originals,
encrypted ones included, hash the same before and after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M1: the full-save capacity test (#523) builds its stream from Bytes
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
`write_full` can't know the output size in advance, so its buffer grows by
doubling and the result kept up to as much again unused: 28 % for a 100 MB
save, 99 % for a 5 MB one. The result is often kept for as long as a document
is open. It becomes the working bytes after edits that need a full rewrite
(repaired files, security changes, redaction) through `write_incremental`, and
it backs Save As ▸ optimized and every new document from Create, Combine,
Extract and Split.
`shrink_to_fit` at the end drops the unused capacity. The bytes are the same.
On macOS the shrink happens in place (2 µs for 100 MB, the buffer doesn't
move), and full-save times are unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`write_incremental` copied the original into a buffer with 4 KB to spare,
then appended the revision. A revision above 4 KB (rotating a few dozen pages,
an image, a long comment thread) outgrew it. The buffer was then reallocated
at twice the size: the whole original was copied a second time, and the
result kept up to the original's size again unused. The engine keeps that
result as the document's working bytes, shared with the renderer and handed
to Save, so the unused half stayed alive. A 100 MB document held a 200 MB
allocation after one such edit, and still did after saving.
The revision is now written on its own, with offsets counted from where it
will start. The result is allocated once at exactly the original's size plus
the revision's, and filled in one pass. The output is byte for byte what it
was before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>