Commit Graph
4484 Commits
Author SHA1 Message Date
mtlcl410andClaude Opus 5.5 aab3682629 M3: right-to-left punctuation doesn't decide a line's direction, and an alphabetic mark gives a taken-back neutral no direction (view.copy-text)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 14:19:30 +08:00
mtlcl410andClaude Opus 5.5 8c1977f051 M3: a ligature counts each of its letters toward a line's direction, and an unattached mark has no direction of its own (view.copy-text)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 13:55:51 +08:00
mtlcl410andClaude Opus 5.5 37d0f37110 M3: nonspacing marks count toward neither direction of a line (view.copy-text)
reverse_rtl's paragraph direction and keep_taken_back_blanks' line direction both leave glyphs
made only of nonspacing marks out of their counts (is_nonspacing_mark), so a few Hebrew letters
carrying many points don't turn a mostly Latin line right to left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 13:09:56 +08:00
mtlcl410andClaude Opus 5.5 ee4b044293 M3: a mark takes the class of the letter it rides on, and the paragraph direction counts letters before marks (view.copy-text)
- resolve_marks applies UAX #9 W1 only to a mark riding on the glyph before it (its anchor), so a
  Hebrew mark placed after a Latin letter keeps its own class and stays with its word.
- reverse_rtl takes the paragraph direction before marks take their letter's class, as
  keep_taken_back_blanks counts the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 12:31:11 +08:00
mtlcl410andClaude Opus 5.5 c4cc9e0c37 M3: a combining mark takes its letter's direction, a line's edge has the line's direction, and overprinting runs are placed by their clusters (view.copy-text)
- reverse_rtl applies UAX #9 W1: a nonspacing mark takes the class of the letter it rides on,
  so a Latin accent at a word end on a right-to-left line stays after its letter.
- keep_taken_back_blanks reads a line's edge as the line's direction (UAX #9 X10) instead of
  right-to-left, so a neutral at the edge of a mostly left-to-right line keeps its place.
- Rows of overprinting runs are ordered by where their clusters start, so a riding mark that
  reaches left of its letter cannot put its run first.
- The two-taken-back-spaces test checks the untrimmed text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 11:56:38 +08:00
mtlcl410andClaude Opus 5.5 94203a2b78 M3: a taken-back space carries the marks on either side of it and reads numbers as right-to-left context (view.copy-text)
The letter after a blank and the marks drawn between them are gathered from
both sides of the blank in x order, so a tanwin pulled back over the space
with its alef starting at the space's own start still moves the space before
the word (PDFBOX-5487). Numbers beside a taken-back neutral count as
right-to-left, as in the bidi pass (UAX #9 N1), so '15)' followed by a full
stop keeps the stop after the number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 08:46:53 +08:00
mtlcl410andClaude Opus 5.5 b5068a2df7 M3: view.copy-text lists the issue 11656 layout test and the narrowed taken-back rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 08:30:56 +08:00
mtlcl410andClaude Opus 5.5 fc8a4d6974 M3: a taken-back space moves only inside right-to-left text, past marks drawn before its letter (view.copy-text)
A neutral taken back over a space keeps the space before it only when the
nearest strong glyphs on both sides are right-to-left (or the line edge), so a
parenthesis opening Latin text is placed by x as on a line without Hebrew.
Combining marks drawn between the space and its letter no longer stop the
move, and the space, the marks and the letter keep drawing order. The pass
now runs after zero-width marks are merged, since that merge relies on x
order. Adds tests for the pdf.js #11656 content stream and for two
taken-back spaces on one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 08:26:02 +08:00
mtlcl410andClaude Opus 5.5 3cbf001256 M3: merge the combining-mark clusters into the copied-text letter order branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 08:08:18 +08:00
mtlcl410 ab0be74613 Merge remote-tracking branch 'origin/fix/rtl-overlapped-space-order' into feat/copied-text-letter-order 2026-11-08 08:06:09 +08:00
mtlcl410andClaude Opus 5.5 cfbe3c9923 Merge upstream/main into fix/rtl-overlapped-space-order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 00:52:01 +08:00
mtlcl410 866e94e9b4 Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/render/src/text/tests.rs
2026-11-08 00:43:32 +08:00
mtlcl410andClaude Opus 5.5 152d696fb0 M3: a space taken back under the next letter stays a word break, so right-to-left words are not split (view.copy-text)
A right-to-left show string in reverse order starts with the space that ends
its word (ISO 32000-2 §14.8.2.5.3), and a word processor pulls the next letter
back over it with a TJ adjustment a little larger than the space's width. The
letter then starts a hair left of the space, so sorting a line by x put the
space inside the word: copied and found text read "המדינ ה" for "המדינה" and
"כולם ?" for "כולם?" (pdf.js #11656), and Find missed both.

A blank glyph has no ink, so its box cannot place it against a glyph that
covers it. On a line with right-to-left text, when the glyph drawn right after
a blank is a right-to-left letter or a neutral, starts at or left of the
blank's start and covers at least half of it, the blank keeps its drawing
place before that glyph (§14.8.2.6.2: the space is a word break in the
character stream). Left-to-right text, a glyph that only touches the blank, and
a blank inside a letter-and-mark cluster keep their x order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 00:42:38 +08:00
FredApps 5644d56c64 Merge pull request #6243 from alehel/refactor/page-press-edit-text
refactor(ui): tell Edit text, the text layer and links whether a press is theirs (router PR 4b)
2026-11-07 17:15:50 +01:00
FredApps 9dbede8709 Merge pull request #5816 from KenM76/feat/unreadable-object-report
feat(cos): list the objects that could not be read in doc_info
2026-11-07 16:01:52 +01:00
FredApps 33e3945c04 Merge main and preserve unreadable diagnostics with cached failures 2026-11-07 15:48:09 +01:00
FredApps 784ce9212b Merge pull request #6497 from helenkwok/web/page-api
pdfcraft-web: window.pdfcraft, the page command API from craft-web-api, opt-in with ?control
2026-11-07 15:39:11 +01:00
FredApps a94e90da65 Merge pull request #6471 from ZIDAN44/fix/6440-add-text-cmyk-gray
Add text: gray or CMYK colour, painted with g or k (#6440)
2026-11-07 15:39:04 +01:00
FredApps 09a63d6dd6 Merge pull request #6197 from KenM76/feat/rich-copy
M3: copy keeps formatting as HTML (Copy with formatting)
2026-11-07 15:38:59 +01:00
FredApps 21747a4e58 Merge pull request #5802 from KenM76/perf/failed-object-cache
perf(cos): an object that can't be read is searched for once, not on every read
2026-11-07 15:38:52 +01:00
mtlcl410andClaude Opus 5.5 f1bd39e578 M3: a stacked, raised or squeezed combining mark stays with its letter (view.copy-text, view.find)
A second mark stacked on a letter now rides on the letter as well as on the
mark before it; a mark drawn up to an em above or below its letter stays on
the letter's line, and the glyph after it continues from the letter; marks in
a zero-width run are ordered by their letter; and a word gap after a mark is
measured from the letter's advance, not the mark's offset start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 18:32:56 +08:00
mtlcl410andClaude Opus 5.5 5307510f66 M3: copied and found text keeps each combining mark after its letter (view.copy-text, view.find)
Text drawn one glyph per show operation, with zero-advance Thai tone marks
starting a hair past their base's advance, came out with the mark after the
next letter ("ห้อง" read "หอ้ง") because a line's glyphs were sorted by x
alone. A combining mark (Mn/Me) of a left-to-right script drawn right after
the glyph it sits over now rides on that glyph: placement sorts clusters, so
the mark keeps its content-stream place. Word gaps are measured between
clusters, and the tight-cluster rule now guards only loose combining signs,
so a Thai or Indic word gap with no space glyph reads as a space.

Fixes #6517

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 17:08:09 +08:00
Helen Kwok a9ccca5b71 pdfcraft-web: say ui.open answers with an error on the web 2026-11-07 15:30:00 +08:00
Helen Kwok 6ec23789c1 pdfcraft-web: say a timed-out call is not guaranteed to have been skipped 2026-11-07 10:28:05 +08:00
Zinadin Zidan 9db177c163 Add text: the ink tests reach the non-number and content_update paths (review)
The hand-edited ink test's "CMYK entry not a number" case had five entries,
so the length check refused it before any entry was read; it now has four.
content_update gets its own refusals: two inks at once, an ink out of
range beside another style change, and gray or cmyk on an image, with the
item list unchanged after each.
2026-11-07 08:05:36 +06:00
Helen Kwok a9e98e0db6 pdfcraft-web: window.pdfcraft, the page command API from craft-web-api, opt-in with ?control
?control attaches the UI control channel (the one --control serves) and installs window.pdfcraft: request, execute (ui.command), commands (ui.commands), docInspect (ui.state), uiInspect (ui.inspect) and screenshot. The channel keeps the accessibility tree of every frame, so it stays off unless the page asks. Opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 08:17:43 +08:00
Zinadin Zidan 2153da0644 Add text: a stored ink is read without copying a long array (review)
Reading an item's /CMYK resolved the value, which copied a direct array,
then collected every entry before checking there were four. A file with a
huge numeric /CMYK paid for that on each content listing and item lookup.
Now a direct value is read where it is, a reference is followed without a
copy, and the array's length is checked before any entry is read. /Gray is
read the same way. A long /CMYK, direct or by reference, and a /Gray that is
a long array still read as no ink.
2026-11-07 03:06:01 +06:00
Zinadin Zidan c44896df95 Add text: an ink is read only from numbers and kept as painted (review)
A stored /CMYK with a non-numeric entry was read from its numbers alone, so
[0 /Bad 0 0 1] became 100 K; a /Gray that isn't a number with a /CMYK beside
it fell through to the CMYK. Both now give no ink, and the text paints in
its /Color.

g and k are written to the thousandth, as rg is, so an ink set or read with
finer digits painted other than what the item, its stored /Gray or /CMYK and
content_list held. The ink is now kept as painted, with its look, when set
and when read back. RGB text is unchanged.
2026-11-07 02:08:46 +06:00
Zinadin Zidan 7e8551e4bc Add text: gray or CMYK colour, painted with g or k (#6440)
page_add_text and content_update took only RGB, so text added to a CMYK
print file painted 0 0 0 rg, rich black on press. They now also take gray
(0 to 1) or cmyk ([c, m, y, k]), painted with g or k and kept in /PCAdded as
/Gray or /CMYK beside /Color, which holds the matching look. An ink applies
only while the colour still shows its look: picking another colour, also in
the Format panel, drops it for good. RGB text is written byte for byte as
before, and a stored ink that doesn't match its colour is ignored.
2026-11-07 01:01:33 +06:00
alehel 513b0fae60 Merge remote-tracking branch 'origin/main' into refactor/page-press-edit-text
# Conflicts:
#	CLAUDE.md
2026-11-06 15:04:17 +01:00
FredApps f42fda7342 Merge pull request #6416 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:58:52 +01:00
dc5cb122db trust certificate key type when CNG algorithm group is unreadable (#6391)
* trust certificate key type when CNG algorithm group is unreadable

When the algorithm group can't be read from the key store due to a provider bug, the certificate should not be rejected: its SubjectPublicKeyInfo already states the key type, and a real mismatch will surface at signing time instead of at enumeration.

Change matches_key to fall back to Ok(()) when key.algorithm_group() returns an error, trusting the certificate's key type; extract the group comparison into key_group_matches(group: Option<&AlgorithmGroup>, want: StoreKey) so the fallback and mismatch cases can be unit tested without a live CNG key.

Test: key_group_matches_trusts_when_group_unreadable failed before the change and passes after.

* Sign: back to LF line endings, shorter test comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:23:18 +01:00
1d71d6039f Add PCX image support to Create PDF conversion (#6396)
* Add PCX image support to Create PDF conversion

PCX is listed as an accepted Create source elsewhere but PdfCraft rejects it, so users cannot convert PCX images to PDF.

Detect PCX by its header in is_image and decode it in embed, adding pcx to CONVERTIBLE so existing from_images and source_kind paths handle it.

Test: pcx_images_become_pages failed before the change and passes after.

* Create: PCX scanlines hold every plane's row, 8-bit indexed images, older versions, standard test files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:16:36 +01:00
KenM76andClaude Opus 5.5 cf3ee49fbc Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-06 06:54:33 -05:00
FredApps 465783fc47 docs: preserve local guidance in house-rules rollout 2026-11-06 12:37:25 +01:00
KenM76andClaude Opus 5.5 1107834209 Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-06 06:03:59 -05:00
KenM76andClaude Opus 5.5 80ab49d09a Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-06 05:59:18 -05:00
FredApps 81b0833b24 Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:02 +01:00
a0811650de Add Pages range field to Compare Files dialog (#6336)
* Add Pages range field to Compare Files dialog

Users cannot limit a comparison to selected pages, so the dialog needs a validated Pages field that blocks Compare on invalid ranges.

Add a Pages text field under New file, keep its text in pages_text, parse comma-separated numbers and ranges to 0-based pages, show an error and disable Compare for zero, reversed, or out-of-range pages, and leave the comparison itself unchanged.

Test: compare_dialog_pages_field_rejects_out_of_range failed before the change and passes after.

* Compare Files: the Pages field limits the comparison, and says what is wrong with a bad range

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Compare: a change across a page break counts on both pages; a bad range is refused, not read as all pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:29:00 +01:00
af76b5ca89 Delete key removes selected content object in edit mode (#6384)
* Delete key removes selected content object in edit mode

The reporter says Delete does nothing for a selected textbox or image in edit mode; the only Delete/Backspace handlers in the UI are for organize pages and comments, so a selected content object has no delete path.

Add Delete/Backspace handling to the object selection input in object_ui.rs that deletes the currently selected object(s), and cover it with a unit test that selects an object and presses Delete.

Test: delete_key_removes_selected_object failed before the change and passes after.

* Tests: the Delete key test takes PageToolContext and imports PagePointer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:10:16 +01:00
Hamza Naveedandskylar taylor-barrick 97cef90e01 Redaction: a pattern nothing paints with any more leaves the page (#3680)
* Redaction: a pattern nothing paints with any more leaves the page

A tiling pattern's cell is a content stream of its own and can draw
anything, text included. Redacting a shape filled with such a pattern
removed the fill from the page's content -- the page looked right, a
black box -- but left the pattern in /Resources /Pattern, and its cell,
with the text that was under the mark, was written into the saved file
unchanged.

The pass does not enter pattern cells, so it cannot redact inside one.
What it can do is notice that nothing paints with the pattern any more.
The graphics state now carries the fill and stroke pattern a `scn` or
`SCN` selected (and `cs`, `g`, `rg`, `k` and their stroke forms clear
it), every painting operator that survives the rewrite records the
pattern it drew with, and a pattern the page selected but no surviving
paint used leaves the page's resources.

Measured on the report's file:

    before   applied: 1, (SECRET) in the saved bytes
    after    applied: 1, no /Pattern on the page, (SECRET) gone

A pattern some surviving paint still uses is kept, and so is its cell:
with two boxes filled from one pattern and only the first marked,
patterns_removed is 0 and the resources keep the entry -- the second box
still shows the word, so it is not a leak.

Report gains patterns_removed.

Fixes #3647.

* Keep a pattern every operator that can paint with it still uses

Two defects CodeRabbit found in the first version of this change, both
of which could remove a pattern the page still draws with.

Pattern use was recorded for path painting only. Text shows with the
fill pattern too -- and with the stroke pattern in the stroking render
modes -- as do image masks and anything a form draws, and a form's
content is interpreted with a graphics state of its own, so a pattern it
inherits is never seen inside it. Content like

    /Pattern cs /P0 scn 20 140 100 30 re f BT (KEEP) Tj ET

with the box redacted therefore lost the fill of text still on the page.
Every operator that can paint with the current pattern now records it:
text (except mode 3, which paints nothing, and mode 7, which only
clips), inline images, and image or form XObjects. Only path painting
knows exactly what it drew and whether the paint survived, so the others
are recorded as they are met. That can keep a pattern whose paint was in
fact all redacted, which costs an unused resource entry, while dropping
one something still draws with takes paint off the page.

/Resources /Pattern may be an indirect object, like any value. Reading
the reference itself as a dictionary found none and pruned nothing, so
on such a file the cell -- with the text that was under the mark --
stayed in the saved bytes, which is the leak this change is for. It is
resolved now, and what is written back is a dictionary of this page's
own, so pruning never reaches through a /Pattern dictionary another page
shares.

The controls in the test: without the first fix the surviving text's
pattern is removed (left: 1, right: 0), and without the second the
indirect file prunes nothing (left: 0, right: 1).

* M8: refuse retained unsafe tiling patterns

* M8: guard shared appearances and narrow pattern scans

* M8: update TLS dependencies for new advisories

* Redaction: the comment on prune_patterns said more than the code does

`prune_patterns` drops the `/Pattern` resource entry; the `scn` that selected
it stays in the content, so the page names a pattern its resources no longer
hold. The comment claimed the selector went too (noted in review).

ISO 32000-1 8.6.6.2 makes that operand a key of the `/Pattern` subdictionary,
so the open question is whether a reader stops reading the stream at the stale
name. `a_page_whose_pruned_pattern_is_still_selected_renders` answers it with
the renderer rather than with prose: the redacted page is saved, reopened and
rasterized, and

* it renders with no error and the same 300x300 page box,
* the text written *after* the stale selector is still painted -- which is
  exactly what a reader would drop if it gave up at the bad operand,
* the redaction overlay is black over the box the pattern filled.

Control: with the drawing after the selector removed, the pixel assertion fails
("the text after the stale selector is still painted"), so it is reading that
text and not a stray pixel.

The selector is left in place rather than cut out: removing operators is a
rewrite of the content this pass does not otherwise do, and nothing surviving
paints with that colour. `pdfcraft-render` is a dev dependency of this crate
only -- layer 4 on layer 3, which the layering allows.

* WIP: pattern guard depth bound sized from the interpreter's

* Pattern tests: keep the guard's cases reaching the guard now a hit path is removed whole

* Pattern automation tests: keep the pattern in use where they test the guard, since a hit path is now removed whole

* Redaction: a form without resources keeps the page pattern it still paints

A form with no /Resources of its own paints with its caller's patterns, but
its surviving pattern paint was recorded only in the form's own output and
never reached the page. With the page's own fill under the mark removed
whole, prune_patterns then dropped /P0 from the page while the form outside
the mark still painted with it, so that fill lost its paint in the saved file.

Pass the painted names up from a form that inherits its caller's resources.
Only the painted side goes up, so this can keep a pattern, never prune one.

* Redaction: a pruned pattern does not stay named by the page tree

prune_patterns writes only the page's own /Resources, and the guard walked
only pages' effective resources. A /Pages node that still named the pruned
pattern kept its cell reachable from Root, so a full save wrote the cell,
text included, while apply returned Ok. Three shapes did it: a page with no
/Parent inheriting from the root, a /Parent that disagrees with the /Kids
tree, and a root /Resources shadowed by a nearer holder that was retired.

Patterns pruned from a page are now unlinked from page-tree holders no page
inherits from, as unlink_doomed does for XObjects, when every /Parent agrees
with the tree. Whenever a pattern was pruned (or a selected page had any),
the guard also inspects the /Resources of every /Pages node Root reaches
down /Kids and up each page's /Parent chain, so whatever stays reachable is
proven safe or the redaction is refused, leaving the document unchanged.

* Redaction: a Type 3 glyph without resources keeps the page pattern it paints

A Type 3 font with no /Resources looks the names in its glyph descriptions
up in the resources of the page or form showing it, so a glyph outside the
mark can paint with the page's pattern. Text recorded only the fill and
stroke pattern of the graphics state, so with the page's own fill under the
mark removed whole, prune_patterns dropped the pattern and the glyph lost its
paint. Showing such a font now keeps every pattern of the scope, as a form
without /Resources keeps what it paints.

* Redaction: say what text-recorded pattern use keeps

Text, inline images and XObjects record their pattern before the hit test,
so a pattern painted only by redacted text keeps its cell. The guard refuses
a cell with text or images; a vector-only cell stays in the file. The comment
said this cost only an unused resource entry. Pin the behaviour in a test.

* Redaction: the page tree is followed up every /Parent link

A full save writes every node a /Parent link reaches, but the guard followed
/Parent only from pages and stopped at the first node it had already seen,
and unlinking never followed it. A /Pages node reached only through the
/Parent of a /Kids node -- a root /Pages with a /Parent, or an intermediate
node whose /Parent leads out of the tree -- kept naming the pruned pattern,
and its cell, text included, stayed in the saved file while apply returned
Ok.

The guard now follows /Parent from every node it visits, page or /Pages, with
the same seen-set against cycles. Unlinking treats a root with a /Parent as an
inconsistent tree, like a disagreeing link, and leaves the decision to the
guard, which inspects every node those links reach and refuses an unsafe
cell.

* Redaction: a long page tree does not exhaust the pattern guard's budget

The page-tree walk counted every node, pages included, against the guard's
16,384-object budget, so redacting a pattern page of a 17,000-page document
was refused with a limit error where it had applied before. Only the holder
nodes the guard inspects count against that budget now; the walk itself is
bounded at a million nodes, as unlink_doomed bounds the same tree.

* Redaction: a Type 3 font set by an ExtGState or shown in a form keeps the page patterns

The resourceless Type 3 check covered only a font set by Tf in the scope that
paints. A Type 3 font without /Resources set through an ExtGState /Font entry
was not flagged, and a glyph shown inside a form with /Resources of its own
recorded its patterns in the form's scope, which the page never saw. In both
cases the page's own fill under the mark was removed whole, prune_patterns
dropped the pattern, and the glyph outside the mark lost its paint.

An ExtGState font is now checked the same way, and a glyph shown in any
nested form marks the page as keeping every pattern it selected. This can
only keep patterns; whatever is kept is checked by the guard.

* Redaction: a pruned pattern a full save would still write is proven safe or refused

The walks that check retained pattern cells follow the references they know
-- resource categories, the page tree, appearances -- but a full save writes
whatever the trailer reaches by any reference. A direct /Parent dictionary, a
/Kids array on a page, or a stray resource entry (/Shading naming the pattern
stream) kept a pruned cell, text included, in the saved file while apply
returned Ok.

pdfcraft_cos gains reachable_objects, which runs the same breadth-first walk
write_full uses (factored out as full_save_order, so the two cannot drift).
Before apply succeeds, every pruned pattern that walk still reaches is
inspected like a retained cell: vector-only passes, anything else refuses
with the existing pattern error. It runs only when a pattern was pruned and
costs one walk of the reachable objects.

* Redaction: a direct /Parent dictionary is part of the page tree too

A /Parent given as a dictionary rather than a reference is written inline by
a full save, but the page-tree walk followed /Parent only as a reference, and
unlinking took such a tree for consistent. A root /Pages whose direct /Parent
held /Resources naming the pruned pattern kept its cell in the saved file.

The guard now walks a direct /Parent like any node (its /Resources, /Parent
and /Kids), and unlinking counts any /Parent that is not the reference /Kids
came from as an inconsistent tree, leaving the decision to the guard.

* Redaction: page-tree walks queue each node once and borrow /Kids

On a hostile tree whose /Pages nodes share one long /Kids array, the guard's
page-tree walk and pattern unlinking pushed every /Kids entry of every node
before checking whether it had been seen, and copied the array on each
visit. Both now queue only nodes not seen yet and read /Kids in place;
unlinking also stops at the first node reached twice, since its /Parent can
agree with at most one of the nodes above it, and leaves that tree to the
guard.

* Redaction: a Type 3 font with /Resources null is resourceless too

/Resources null is the same as no /Resources, but the check counted any
entry, so such a font's glyph outside the mark lost the page pattern it
painted with. A Type 3 glyph shown by a kept annotation appearance is a known
limit, pinned as an ignored test: appearances are not interpreted for pattern
use, and under ISO 32000 the glyph resolves in the appearance's own resources.

* Redaction: a pruned pattern is recorded along its whole reference chain

A pattern name can lead to a reference-valued object whose value is the cell
(/P0 7 0 R, object 7 being 6 0 R). Pruning recorded only object 7, so the
reachability backstop checked an object nothing reached while a stray
reference kept the cell, object 6, in the saved file. Every object along the
chain is recorded now, bounded like the other walks.

* Pattern tests: pin the file identifier where two saves are compared

main now gives a redacted rewrite a new identifier from fresh entropy, so an
incremental and a full save after Apply differ in /ID alone. The test that
checks they are the same save passes fixed entropy to both.

* Redaction: replies and pop-ups removed on other pages feed the shared-appearance check

Apply checks every page for retained unsafe pattern cells when a removed
annotation's appearance is shared with one that stays on an unselected page.
Only annotations removed under a mark fed that check. Replies and pop-ups
that go with them in step 2b (removed_everywhere), often listed on another
page, did not, so a text-bearing cell in an appearance a kept annotation
shares with them escaped the all-pages check and Apply returned Ok.

Their appearance references now go into removed_appearances too, with the
same limit. Raised by FredApps' review of the rebase.

---------

Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-11-06 10:01:42 +01:00
d7ce875dd6 redact: independent redaction proof machinery (snapshot, sweep, budget) (#5557)
* redact: add the independent redaction proof machinery

* redact: harden the proof's paint check and count removed codes

The manifest counts character codes, not raw bytes: a composite font's
codes are several bytes, so the byte length overstated what was cut.

Annotation appearances are checked whatever their storage (a direct
dictionary has no reference of its own to dedupe on), and a stream
shared by several annotations is checked at each annotation's own
placement. The graphics-state stack and the clip holes of one walk are
bounded; deeper or more is unverifiable, never unbounded memory.
Even-odd clips cut holes only where the rule can be told cheaply - all
subpaths axis-aligned, disjoint, few - and anything else keeps its
paint reported, failing closed. The /W widths of a descendant font
cost bounded work, like a CMap.

* redact: settle the survivors a page sweep counted before its work ran out

A page that the search budget stops on left the counts made on earlier
pages unsettled: the survivors were seen but never reported, and the
regions that owned them were never marked. The sweep now stops and
still settles, so a located survivor is reported where it sits (the
page reads unswept either way, and the proof still fails).

* redact: prove the redacted output of every supported encryption

The authenticated-reopen regression now covers all four crypt
algorithms the writer supports, not only AES-128: the caller's
credentials prove the saved output, and empty or wrong credentials
still fail closed.

* redact: answer the review round on the proof

A glyph lying under several regions is now accounted for by every one
of them, so overlapping marks each carry their own share in a shared
proof instead of the first one absorbing the rest and the others
reading as if no text was there.

Each finding is recorded once per region as it is found, so a page
that paints over a region a thousand times neither grows the report
nor the memory with repeats.

The byte search walks the haystack once (the pattern's prefix function
is built once), so a needle of one letter over a haystack full of it
can no longer spend comparisons the search budget never charged for.

A numeric XML character reference may carry any number of leading
zeros; the padded form now decodes like the short one, so a survivor
spelled that way in the XMP is still found.

The /W widths of a descendant font are paid for from the shared work
budget: a form invoked again rebuilds its fonts, and a per-font
counter alone let the same array be paid for many times over.

* redact: sweep the proof's survivors out of every place a reader could look

Plant the removed text where the review listed it and expect the sweep to
report it: a compressed object stream, a page thumbnail, a form field
value, an XFA packet, and an object nothing refers to. A file whose page
content is bigger than the proof may decode for one stream is now shown
to come out unverified on its surfaces and its region, never clean.

* redact: spare the names a redacted word legitimately sits in, and charge failed decodes

A redacted word that is also a structural name (a /Subtype, a /Filter, a
/BaseFont) is structure, not a leftover copy: the name surface is now
swept against the matches the input itself had, and only a surplus of
matches is a survivor. The input's names are counted when the snapshot is
taken, for the region's needles and for strings added later alike.

A decode that fails after doing part of its work paid nothing: the
proof's cumulative decode allowance now charges every attempt, failures
included, so the same bomb cannot be decoded (and fail) again and again
for free.

* redact: count the input's names once each, and say so when the count runs out

The name allowance counted one entry per occurrence, so a document whose
pages repeat the same structural name paid for the same scan again and
again; names are now kept once, with their count. A name search that runs
out of work read as no matches, which would have swept names with none
allowed: that is reported on the text surface now, and the proof fails
rather than pass on an uncounted allowance.

* Bound redaction proof searches across many needles

Assisted-by: OpenAI Codex

* Redaction proof: drop find_all, which nothing calls

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: put aho-corasick in its sorted place

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Redaction proof: review notes

A word split across a TJ array is looked for whole. Overlapping marks count a
removed copy once. A spent decode budget is a problem, not a raw-only stream.
Short needles stay out of the attachment text search, and an indirect /Decode
counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:15:07 +01:00
Dylan M. Taylor 5aaa8186fd Merge pull request #6375 from dylanmtaylor/land-sign-remaining
sign: land the still-valid parts of #6358, #5187 and #5173
2026-11-05 22:00:25 -05:00
LetoCoder26 fbe079c953 fix(sign): compare the catalog's /Perms by content under P=1
Rebased #5173 onto 9afa1266. Its later-signature predicate is superseded by
#6364's object-based check, so only the /Perms rule lands: under DocMDP P=1 a
/Perms that differs from the signed catalog's, by content, is a structure
change however it was rewritten.

Co-Authored-By: LetoCoder26 <leto@lvanwesten.eu>
2026-11-05 21:43:14 -05:00
LetoCoder26 560c68db89 fix(sign): an existing object is judged by the role it had when signed
Rebased #5187 onto 9afa1266. Landing the signed-role rule only: an existing
object that gains form or annotation keys is judged by what it was when
signed, and a signed annotation turned into another kind is no comment.

The two P1 regressions the original PR carried are not landed: the signature
decision still reads the new /FT (a signed widget relabelled as a text field
is not signing), and the dropped-/Fields check runs before the form-key
comparison (already carried by #6358's lost_entries).

Co-Authored-By: LetoCoder26 <leto@lvanwesten.eu>
2026-11-05 21:41:29 -05:00
Martin Ball ddeca878df sign: judge the signed page tree, catalog and form by their signed roles
Rebased onto 102cae3b/9afa1266 for landing (#6358). The signed page tree's
nodes, a /Kids array of its own, the catalog by object number and an indirect
AcroForm keep the roles they had when signed; a field dropped from the form is
no permitted change at any level; a signed widget's /Rect or /F is page content.

Co-Authored-By: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-05 21:40:30 -05:00
Dylan M. Taylor 9afa126653 Merge pull request #6364 from benandez/fix/sign-p1-only-invisible-timestamps
sign: under no-changes certification, only invisible document timestamps may follow
2026-11-05 19:51:52 -05:00
Dylan M. Taylor 102cae3bef Merge pull request #6361 from benandez/fix/sign-classify-by-signed-role
sign: classify changes by signed object role
2026-11-05 19:28:33 -05:00
Martin BallandClaude Opus 4.8 e1a3c033aa sign: under no-changes certification, only invisible document timestamps may follow
DocMDP P=1 allowed any change classified as a signature. A signature added
after signing now breaks the certification unless it is a document timestamp
drawing nothing (no positive-area /Rect, or Hidden/NoView). The added objects
are judged directly, so a widget with no /T or one only in a page's /Annots is
covered. The document security store and a genuine timestamp still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-06 00:19:58 +00:00
Martin BallandClaude Opus 5.5 de344a4bb1 sign: compare annotation lists by what resolves, within a bound
An annotation list is compared by the entries that resolve in each revision,
and an unchanged list is no change. Expanding annotation lists is bounded per
classification. A damaged slot rewritten with fewer accepted generations is the
same slot when nothing signed refers to a generation it gave up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:10:06 +00:00