Commit Graph
4844 Commits
Author SHA1 Message Date
Grzegorz MartensandClaude Opus 5.5 d289038ca0 M2.6: autotagging reads page content with its /DecodeParms given by reference
Autotagging a text-only page rewrites its content, marked, from what it
read. It read each stream through its filters with /DecodeParms only
when direct, so content behind a predictor whose parameters are given
by reference was read with the predictor's tag bytes in it, and written
back so: with eight-byte rows, a \0 every eight bytes split `24 Tf` into
`2 4 Tf` and the text's string, and the tool returned ok. Found by the
consumers review of the CodeRabbit round; inherited from main.

It now reads through DecodeBudget::decode_strict, which reads the
parameters in the document and is strict on damage as before, within
the same 16 MiB. pdfcraft-a11y no longer uses pdfcraft-filters.

Test: autotag_text_reads_content_with_parameters_given_by_reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 084fa0927e M2.6: say why pruning reads a form's /Subtype only when direct
The sweep flags the read in Pruner::form, moved there by this change. A /Subtype given by reference reads as no form wherever the workspace tells forms from images; resolving it is a change across all of them, and the comment says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 75bf5ed264 M2.6: a test pins that pruning reads the forms of damaged content at the depth limit
Forms are read 8 deep. A damaged form that deep has every form it
names read as drawn, so one drawing through resources another page
shares keeps what it draws: a_damaged_form_at_the_depth_limit_has_its_forms_read.
Mutation testing found nothing pinned that boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 749a323342 M2.6: tests pin damage in an earlier filter stage, on another page, and in the saved file
- cos: a filter chain whose first stage stops at damage and whose
  second reads all it was given is damaged.
- optimize: a page damaged partway stops resampling for the whole
  document, so another page drawing the same image whole doesn't get
  it resampled.
- redact: after redacting content with /DecodeParms by reference, no
  stream in the saved file holds the text, read with its parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 8bf7412220 M2.6: pruning reads every form that damaged content may draw
Earlier in this change, a page or form damaged partway, like one that
doesn't decode, kept its own /XObject dictionary whole and was read no
further. So a form it draws was never read: when the form's resources
are shared with another page, the names the form draws there went, and
extracted or split pages lost those images. On main the part before the
damage was read, so forms drawn there kept theirs, and forms drawn past
it didn't.

Now every form the resources of such content name is read as drawn, as
any of them may be drawn before the damage or past it. Each set of
resources has its forms read once.

Test: extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources,
with a page and a form each damaged before and past the form they
draw, and each not decoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 0a350f6809 M2.6: nothing after a damaged content stream, or in one that doesn't decode, is rewritten
A page's content streams are read as one stream, so what a stream
damaged partway hides past the damage is unknown to the streams after
it too. A string or comment the damage leaves open runs on into the
next stream, and operators the damage hides can change the graphics
state the later streams draw in. Marking a tag's content as an artifact
took what read as a BDC in a later stream for the tag's own, and could
rewrite bytes inside a string. Moving an image after the damage worked
the move out without a cm the damage may hide.

Now an edit refuses a change to any stream after one damaged partway
(rewritten_contents), and an image isn't arranged into a new stream
placed after one. Marking an artifact or a decorative figure refuses
content that reads as the tag's in a stream after the damage. Edits and
marking before the damage go on as before.

A content stream that doesn't decode at all was read as empty and
whole. A styled edit whose paragraph marker closes past the page's last
operator appended the close to it, so the edit replaced that stream
with one holding only the close. Edits now refuse a change to such a
stream, and still go on around it. Marking an artifact or a decorative
figure whose content isn't found takes it as possibly in such a stream,
as it does for content past the damage, and refuses rather than take it
for absent.

Tests: an_edit_never_rewrites_a_content_stream_damaged_partway (an edit
after the damage refused, one before it made),
text_a_damaged_stream_runs_on_into_is_not_edited,
images_after_a_content_stream_damaged_partway_are_not_moved,
a_content_stream_that_does_not_decode_is_never_written_over,
a_tags_content_after_a_damaged_stream_is_not_rewritten,
a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent,
and a_tags_content_in_a_stream_damaged_partway_is_not_rewritten now
checks the damaged stream itself and marks content before the damage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 ebf9c7a3ce M2.6: a damaged-stream refusal says the content may be past the damage
Since marking a figure decorative or a tag as an artifact refuses content
not found beside a damaged stream, the refusal also covers content that
only may be past the damage. Its message said the content is in a damaged
stream; it now says it is, or may be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 2d672d9517 M2.6: redaction keeps an image the page still draws behind parameters by reference
Once a redaction removes an image or form under a mark, it takes the name
out of the page's resources unless the page's content still draws it
elsewhere. It read the page's streams again for that with `decoded`, which
reads `/DecodeParms` only when direct, so a stream behind a predictor whose
parameters are an indirect object seemed not to draw it, and the page lost
the image there.

It now looks in what it read for the redaction, with the parameters by
reference, and decodes again only the streams it has just written, which
have none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 91968610c3 M2.6: redaction reads content with its /DecodeParms given by reference
`DecodeBudget::decode_strict` read a stream's `/DecodeParms` only when
direct, so content behind a predictor whose parameters are an indirect
object decoded with the predictor's tag bytes left in. Redaction then found
no glyph under a mark, left the text in the file and reported the mark
applied. ISO 32000-2 lets those parameters be indirect.

`decode_strict` takes the document and reads through `decoding_view`, as
`decode_with_damage` does. Its other readers read the parameters too:
Remove Hidden Information's hidden text and layers, the audit's covered-text
check, and edit's backdrop scan.

The accessibility_mark_tag_artifact description says that content which is,
or may be, in a stream damaged partway is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 3bf6c6f027 M2.6: marking content as an artifact refuses what may be past the damage
A tag's content not found before the damage in a damaged stream may be
past it. Marking a figure decorative took it for absent: it succeeded,
took the figure out of the structure tree and left its content tagged.
Marking a tag as an artifact said its content wasn't on the page. Both now
refuse with `Damaged`, as when the content is before the damage, and change
nothing.

Clearing the parent tree read the page's `/StructParents` only when direct,
so with it given by reference (ISO 32000-2 lets it be) the tree went on
pointing the content at the retired tag. It is read through the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 69e26167e4 M2.6: a page's content is read with its /DecodeParms given by reference
`decode_with_damage` and `decode_contents_with_damage` read a stream's
`/DecodeParms` only when direct, as `Stream::decoded` does, so content behind
a predictor whose parameters, or a number in them, are an indirect object
decoded with the predictor's tag bytes left in, and was taken as whole. Edit
then listed and wrote back the wrong bytes, and pruning dropped an image such
a form draws. ISO 32000-2 lets those values be indirect.

- `decode_with_damage` takes the document and reads the parameters through
  it, and so does `decode_contents_with_damage`, and `decode_contents` with
  it: printing, accessibility's reading of a page and a page copied as a form
  read them too. Code that calls `decode` on a stream itself doesn't yet.
- It reads through `Document::decoding_view`: `with_direct_parms`, but a copy
  of the stream holds only its `/Filter` and the parameters, its data shared,
  so a stream a page's `/Contents` lists again and again isn't copied whole
  for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 3fec35e1ea M2.6: tests pin that content which doesn't decode is kept and edited around
A stream that doesn't decode at all is not damage to write over: edit and
accessibility keep it as stored and go on with the page's other streams,
and only running past the page's decode allowance refuses. Nothing pinned
either, nor that selecting objects reads a stream of a few MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 50d0fcc403 M2.6: pruning keeps what a page or form damaged partway may draw
Extracting, splitting and combining pages drops the `/XObject` entries no
page draws. A page or form damaged partway was read up to the damage as the
whole of it, so an image named only after the damage was dropped, though
the stream still names it and other readers may still read that far.

- `content_bytes` says whether a page's stream is damaged, and pruning keeps
  everything such a page could reach, as it does for content that doesn't
  decode. A form is read the same way.
- A page made a form XObject (stamps, watermarks, backgrounds from a PDF)
  still draws what decoded before the damage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 480fdb114a M2.6: Optimize leaves a page damaged partway as it is stored
Optimize read a page's content up to any damage in its streams as the whole
of it. Merging a page's image strips then wrote the page's content again as
one stream, without what followed the damage, and the image pass measured
each image by its uses before the damage only, so one drawn larger past it
could be resampled below that use.

- `DecodeBudget::decode_contents_with_damage` reads a page's `/Contents` as
  `decode_contents` does, each stream also saying whether it is damaged
  partway; `decode_contents` is it without the flag.
- `content_bytes` gives `None` for a page with a damaged stream, as for one
  that doesn't decode: the content is not all known. Its strips aren't merged
  (`merge_page` already leaves such a page alone), and no image is resampled
  (`effective_resolutions` gives every image `INFINITY`).
- A form damaged partway leaves the measuring incomplete in the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
Grzegorz MartensandClaude Opus 5.5 712345d7a1 M2.6: an edit never rewrites a content stream from the part before its damage
A Flate stream damaged partway decodes to what came before the damage, and
`DecodeBudget::decode` gave that back as the stream's data: the decoder's
partial flag was dropped in `Stream::decoded_within_counted`. Editing a text
line, a block, an image or an added item in such a stream wrote it again from
that part, so everything after the damage was lost on save, though other
readers may still read it.

- `DecodeBudget::decode_with_damage` decodes as `decode_within` does and says
  whether the stream is damaged partway. A missing checksum or end-of-data
  marker is no damage, as before.
- Edit reads the page's streams with it (`ContentStream`), and
  `rewritten_contents` refuses, before adding anything, a change to a damaged
  stream. Changes in the page's other streams go on, and the damaged one stays
  as stored.
- Selecting objects (`editable_objects`, moving added items) refuses a page
  with a damaged stream, as it refuses one that doesn't decode: what follows
  the damage could change every later item's graphics state.
- Marking a tag as an artifact or a figure as decorative refuses when the
  tag's content is in a damaged stream (`ArtifactError::Damaged`,
  `AltError::Damaged`), and changes nothing, the structure tree included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-07 12:37:35 +01:00
xullul 4b2a151012 M7: count each grouped placement once across preparation passes
Change:
- Read and count only the placements a preparation pass newly inlines. The
  others were read on an earlier pass in the context they still have, so a
  group refused where it is drawn (translucent, for example) is no longer
  counted again on every pass: 33 such placements beside one text group used
  to reach the 64-placement limit on the second pass and prepare nothing. A
  form refused in one place is still read where an expanded parent draws it
  anew. If the page doesn't parse back to the operators written, every
  placement is read again, as before.
- Read a group refused because its resources needed renaming again, without
  counting it, once names merged in for other groups make the renaming
  unnecessary (checked from its resource dictionaries alone, without decoding
  its content), and drop its earlier refusal when it is then prepared.

Validation: edit 470 and the 2 automation text_prepare tests passed;
formatting, strict Clippy for pdfcraft-edit, layers and assets passed. The
wasm check was not run locally (the wasm32 target is not installed), nor were
the engine suite and the workspace sweep. Each new regression failed with its
fix disabled.
2026-11-07 18:34:05 +07:00
vangel76andClaude Sonnet 5.5 81af7f1cd9 Merge upstream/main into feat/redact-mark-regex
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:54:30 +01:00
vangel76andClaude Sonnet 5.5 8fabc86f3a Merge upstream/main into perf/write-full-retain-parsed-objects
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:52:43 +01:00
mtlcl410andClaude Opus 5.5 3a3fe00f69 M2: without codespace ranges, a bfchar CID mapping matches only codes of its own source length (view.render-cid-fonts)
hayro-cmap patch 4: text CMaps record each bf entry's source length
(BfRange::code_len). Where no codespace range gives a code its length,
the bfchar fallback of lookup_cid_code takes the entry's own length, so
the first byte of <fe96> is no longer taken for <00fe>. CMaps with
codespace ranges are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 18:50:32 +08:00
mtlcl410andClaude Opus 5.5 2c1b903075 M2: a page whose /MediaBox has no area is US Letter and shows its content (view.render-pages)
- hayro-syntax patch 44: a /MediaBox without area counts as missing, so
  the page is sized, placed and clipped by the same Letter region and its
  content is drawn (MuPDF and PDFium both draw it on 612 x 792).
- pdfcraft-model: Page::crop falls back to Letter for such a box too.
- vendor/README.md: the raw-crop summary names the Letter fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 18:44:18 +08:00
vangel76andClaude Sonnet 5.5 e09f87ad55 Merge upstream/main into contributors-vangel76
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:36:39 +01:00
xullul a453112151 M7: read each grouped form once and check PUA alternate meanings
Change:
- Leave a form found to be artwork, or refused for a reason in the form
  itself, out of later preparation passes, so a page with many logos or
  unsupported groups no longer reaches the 64-placement limit and prepares
  nothing. ungroup_ops now marks each refusal as intrinsic or contextual;
  contextual ones (translucency, the resources it would merge into, pattern
  placement, the decode budget) are re-evaluated where the form appears, so
  a form refused in one place is still prepared where a parent draws it.
- Refuse a page-level Do with an operand too many for that placement only:
  the form found by its first operand may not be the one drawn. Check a
  form's own operand counts before the nested walk, as an intrinsic refusal.
- Use the Windows PUA alternates only when every kept alternate whose code
  the PDF's map names a Thai character names that alternate's base
  character, so alternates in another order that the map reveals are refused.

Chores:
- Say that the font program confirms each recovered glyph's advance class,
  not its identity, and document the alternate check.

Validation: with and without craft-fonts, fonts 105 (+3 build-input), edit
468, engine 482 (+28 integration) and automation 50 + 352 passed; editor flows
248 passed. Formatting, strict Clippy, layers, assets and parity passed; the
wasm check was not run locally. Each new regression failed with its fix
reverted. Real-PDF edits on Royal Gazette 26288 kept their source fonts.
2026-11-07 17:00:48 +07:00
mtlcl410andClaude Opus 5.5 cdb94e6611 M2: a page without a /MediaBox is US Letter (view.render-pages)
A page with no /MediaBox, its own or inherited, rendered as A4, while
PdfCraft's page model and inspector already sized it as US Letter and
other readers draw it at 612 x 792 pt (PDFBOX-4001-reduced.pdf).
ISO 32000-2 §7.7.3.3 Table 31 requires the box and gives no default;
follow the readers. `base_dimensions`' fallback for a visible region
without area uses the same Letter size, so a /MediaBox [0 0 0 0] page
is Letter too. Vendored hayro-syntax patch 44. The model test pins the
size it already gave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 17:17:26 +08:00
Josias Eli Rodriguez 6e0d0fde3f redact: treat unusable /QuadPoints as absent in every reader
A quad counts only when all 8 entries are present, numeric and finite.
Anything else contributes nothing — never folded, shifted or zero-filled
into a box the document does not describe. With no usable quad the
existing /Rect fallback runs, and with neither the mark stays unreadable.

The rule lives in one helper, geom::quad_points::usable_quads, and redact,
render-inspect, annot summaries and annot appearance drawing all read
through it, so the outline shown is the area apply removes. Redact also
follows indirect numbers in /QuadPoints now, which it used to drop.
2026-11-07 03:04:51 -06:00
mtlcl410andClaude Opus 5.5 37e5708ed2 M2: a CMap's bfchar mappings read codes at their codespace length (view.render-cid-fonts)
A CMap that writes its CID mappings as `bfchar` was looked up without
the code's byte length, so the first byte of a two-byte code matched a
two-byte entry of the same value and drew as its own glyph: `<fe96>`
drew as "þ–" (PDFBOX-1282, Characters_Arial.pdf page 3). The bfchar
fallback of `lookup_cid_code` now applies only when the codespace
ranges give the code the length being tried (ISO 32000-2 §9.7.6.2),
byte by byte; a CMap without codespace ranges keeps the old fallback.
An unmapped code still advances one byte. Vendored hayro-cmap patch 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 17:03:56 +08:00
xullul 7634ac8064 M7: address review of grouped Thai text preparation
Add:
- Report text grouped more than eight levels deep instead of leaving it
  silently grouped, and refuse forms whose resource-naming operators carry
  surplus operands, which would otherwise lose nested artwork when inlined.
- Return the applied preparation's own counts from Session::prepare_text, so
  text_prepare no longer reports a separate preview's.
- Shape a THSarabunPSK program that has its own Thai mark positioning by that
  layout, in its own codes. A span the subsets can't show in place is shaped
  whole, Latin rows included, by a complete program of the same face and style,
  such as a CFF face from Load font.

Change:
- Expand only forms that show text, directly or through nested forms; image-
  and vector-only forms stay grouped and uncounted. Match a Do's raw resource
  name bytes, and refuse RGB-blended groups on pages naming no blending space.
- Check the recovered THSarabunPSK map and the Windows PUA alternates glyph by
  glyph against the subset's advances, leave dropped glyphs unmapped, and
  insert the repair after the last endcmap.
- Shape a Thai run split across same-face subsets cluster by cluster and skip
  subset glyphs that are empty. Take the legacy path only for programs in the
  legacy glyph order, needing only the glyphs the new text uses.
- Require GPOS mark/mkmk in the script and language system HarfRust selects.
- Match same-face candidates by weight and slant from the name, descriptor
  weight or a non-zero ItalicAngle; the Italic flag alone is ignored, as
  exporters set it on upright faces. Never complete bold or italic text with
  the Regular bundled Thai face.
- Name the missing character, or the unavailable builtin:thai choice, in errors.

Chores:
- Document text_prepare's fields, builtin:thai, the app's refusal reporting
  limits and the new routing; extend the text-edit parity evidence. Tests that
  need craft-fonts now say they skipped instead of returning silently.

Validation: with and without craft-fonts, fonts 105 (+3 build-input), edit
465, engine 482 (+28 integration) and automation 50 + 352 passed; editor flows
248 passed. Formatting, strict Clippy, layers, assets and parity passed; the
wasm check was not run locally. Each new regression failed with its fix
reverted. Real-PDF edits on Royal Gazette 26288 kept their source fonts.
2026-11-07 15:59:38 +07:00
Helen Kwok a9ccca5b71 pdfcraft-web: say ui.open answers with an error on the web 2026-11-07 15:30:00 +08:00
Zhihui Song 014b67915b M10: adopt file-backed loading and record review fixes in the progress log 2026-11-07 15:07:16 +08:00
Zhihui Song 596f8169e3 M10: use approved printing dependencies and retain the Direct2D crash fix 2026-11-07 14:49:08 +08:00
Zhihui Song d20f563ad7 M10: preserve existing LF endings in dependency update 2026-11-07 14:17:01 +08:00
Zhihui Song b56cb0ca9e M10: avoid Windows StorageFile access denial when printing 2026-11-07 14:15:26 +08:00
xullul c7e8d127af M7: edit grouped Thai text while preserving source formatting
Add:
- Prepare supported Form placements as original page operators through the
  existing-text UI and text_prepare, with shared-form isolation and undo/redo.
- Repair narrowly identified Thai exporter maps on copied page fonts, recording
  repairs without changing the embedded source glyph programs.

Change:
- Reuse legacy Thai Regular/Bold glyph codes and same-face punctuation subsets;
  retain point size, spacing, horizontal scale, rise, colour and paint settings.
- Keep cross-family Thai replacement explicit and write matching bundled Thai
  subsets through the existing bounded outline path rather than static embedding.
- Keep replacement-only menu choices out of the source-font preview atlas so
  opening an ordinary paragraph does not wait an extra frame for unused fonts.
- Refuse unsafe compositing, tagged or malformed groups, and missing contextual
  glyphs without losing the user's document or changing other placements.

Chores:
- Document the editing limits and headless workflow; keep text-edit parity partial.

Builds on dekkeng's Thai fallback work in #2811 (fba0a29a) and mtlcl410's Form
Ungroup implementation in #3067 (6eafca9c), adapted to the current module layout.
The Ungroup menu and older UI font-priority changes are not included.

Refs #3185, #2964 and #2142. Synthetic regressions cover source-font preservation,
shared forms, unsafe refusals, layout, undo/redo, rendering and save/reopen.

Validation: library suites 1,675 passed; automation integration 331 passed;
document-font integration 2 passed; editor flows 248 passed. Existing ignored
tests remain. Formatting, scoped strict Clippy, layers, assets, parity and wasm
checks passed. The unchanged opacity regression failed before the choice-only
atlas correction and passed afterwards.
2026-11-07 12:27:29 +07:00
xullul 77cebccda2 M7: adapt embedded Thai source fonts without replacing their programs
Add:
- Recover editor-only Unicode cmaps for Identity-H TrueType subsets and shape
  verified THSarabunPSK positional glyphs into their original PDF character codes.
- Recognize narrowly fingerprinted damaged Thai exporter maps without guessing
  mappings for unrelated fonts; callers must record and isolate any repairs.

Change:
- Preserve the original PDF font programs and refuse missing contextual glyphs.
- Allow modern Thai GPOS positioning without requiring optional GSUB, adapting
  that correction from dekkeng's PR #2811 at fba0a29a.

Refs #3185 and #2142. Font/edit tests: 535 passed. Formatting, scoped strict
Clippy, layers, assets, parity and the fonts/edit wasm check passed.
2026-11-07 12:26:35 +07:00
mtlcl410andClaude Opus 5.5 5c2f4b8383 M2: the oversized-/Rows fax test also checks white rows after the first (hayro-syntax patch 41)
Through /Indexed, samples past the decoded data read as black, so an
all-black image could not tell a decoder that stopped after one row
from one that decoded all four. A second Group 4 image striped black,
white, black, white (horizontal-mode rows, ITU-T T.6 §2.2.3.3) is now
rendered through both colour spaces with every /Rows value, and each
row is checked for its own colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 12:54:37 +08:00
mtlcl410andClaude Opus 5.5 44428b36d3 M9: a signed number saved back as an integer is no change after signing (sign.changes-after-signing)
ISO 32000-2 §7.3.3: wherever a real number is expected, an integer may
be used instead. PdfCraft's own writer writes a whole real as an
integer, so a form fill that rewrites a signed widget turned
/Rect [20.0 20.0 120.0 40.0] into [20 20 120 40] and was refused as a
page content change. same_object now compares an integer and a real by
value (exactly, in i128), for the widget /Rect and every other signed
comparison, such as a page /MediaBox rewritten when a comment is added.

Also states the reordered page's verdict as None explicitly, in a test
of its own next to the one from #5962.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 12:46:10 +08:00
mtlcl410andClaude Opus 5.5 ae79de8deb test(render): check a page whose box has no area on the render path too, and the inherited-box fallbacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 12:45:56 +08:00
mtlcl410 a3fc090067 Merge remote-tracking branch 'upstream/main' into fix/empty-page-boxes 2026-11-07 12:40:53 +08:00
mtlcl410andClaude Opus 5.5 78d90416e6 M1: the renderer reads a cut-off last update's objects, as the document model does (core.repair-broken-files)
When a file's last incremental update stops after its cross-reference rows,
with no trailer or startxref, the last startxref in the file is the older
revision's. pdfcraft-cos finds no startxref in the last 4 KiB and rebuilds
from object headers (latest objects win); vendored hayro-syntax read the older
table, so the view showed the older revision (PDFBOX-3934: 12 pages, the model
13). ISO 32000-2 §7.5.6: a later section supersedes earlier ones; repair is
unspecified and PDFium, MuPDF, Poppler and qpdf take the latest objects.

hayro-syntax patch 43: when the last startxref is more than 4 KiB from the end
and N G obj headers follow it, Pdf::new_with_password rebuilds first; the older
table is read only when the rebuilt one has no page. Trailing data without
object headers and a startxref near the end read as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 12:38:45 +08:00
mtlcl410andClaude Opus 5.5 d4b4218cf1 M2: a fax image with more /Rows than its /Height is decoded to its height (hayro-syntax patch 41)
A CCITTFaxDecode image whose /DecodeParms /Rows exceeds the image's
/Height (PDFBOX-771: /Rows 1084975104 on 2,592 columns) asked the decoder
for /Rows rows; the size check refused that, and every page drew blank.
The image is /Height rows (ISO 32000-2 Table 87), so the decoder is asked
for those; /Rows still counts for a stream decoded without an image's
height. pdfcraft-cos already decodes /Height rows; a test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 11:55:32 +08:00
mtlcl410andClaude Opus 5.5 e6c399811d fix(render): an image whose PNG predictor was declared but never applied draws its samples (hayro-syntax patch 40)
A writer declared /Predictor 15 on an image and its soft mask without
applying it, so the inflated data is exactly /Height rows of the row length
with no filter-type byte before each (ISO 32000-2 §7.4.4.4 requires one per
PNG-predicted row). Read with the predictor, every row's first sample was
taken for a filter type, the image came out a row short and shifted, and the
page drew it in fragments; image export said its data was shorter than its
size. PDFium, MuPDF and Poppler read such data as the samples it is.

Only that exact length takes the new path, and only for the last Flate
filter of an image whose height is known; anything else is predicted as
before. The renderer (vendored hayro-syntax, patch 40) reports it with
warn!, like its other lenient filter decodes; pdfcraft-filters returns it as
tolerated damage (partial in tolerant decoding, an error in strict), through
the new decode_image_tolerant_within that pdfcraft-cos uses for images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 11:05:20 +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
mtlcl410andClaude Opus 5.5 715c310f5b M9: a signed string respelled in hexadecimal is no change after signing (sign.changes-after-signing)
A string object is its bytes; literal and hexadecimal are two ways to
write them (ISO 32000-2 §7.3.4.1-7.3.4.3). The modification check
compared objects with the derived equality, which also compares how a
string was written, so a later save that rewrote the catalog's /Lang
from (en-US) to <656E2D5553> made an untouched signature read as a
document structure change.

same_object now compares strings by their bytes, and every comparison
in changes.rs goes through it: the unchanged-object skip, the catalog,
its /AcroForm and /Perms, developer extensions, signed widgets, page
resource containers, the security store and the timestamps check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 10:25:41 +08:00
mtlcl410andClaude Opus 5.5 ef2d762ec5 Merge upstream/main into fix/sign-string-bytes-not-spelling
Conflict in crates/sign/src/pdf/changes.rs: main moved the signed-page
check into ChangedObjectClassifier::page_change_kind. The method now
uses same_but_annots, as the free function it replaced did on this
branch; the branch's tests reach it through a test helper of the same
name, and both test sets are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 10:20:53 +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
mtlcl410andClaude Opus 5.5 0805f07b89 M2: the layer clip coverage test checks both sides of the limit
The first clip is asserted to fit, and a fill inside it shows within the
circle only; a scene with room for exactly one path shows the clip pushed
after a fill is itself refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 08:33:24 +08:00
mtlcl410andClaude Opus 5.5 fe5fa9e908 fix(render): a number token the scanner can't read is skipped whole, so 1beginbfchar doesn't start mappings (hayro-postscript patch 2)
A number read stops at the first byte that can't be part of a number,
which may be inside the token: `parse_object_or_skip` then read the rest
(`beginbfchar` in `1beginbfchar`) as a token of its own, and the pairs
after it became mappings. The rest of the token is now skipped with the
failed number, one failure per token, as PostScript reads a run of
regular characters as one token.

The render test's run of unreadable tokens before the mappings is spaced:
a run of `-` with nothing between them is one token now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 08:26:11 +08: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
mtlcl410andClaude Opus 5.5 4bba22a122 M2: strokes, fast rectangles and layer clips past the rasterizer's coverage are left out too (view.render-error-isolation)
Each of the dispatcher's other room checks gets a case: a page whose only
coverage is strokes, or axis-aligned rectangles, drawn 2^10 times through
forms, then a red marker through the same path, rendered with the
rasterizer kept to 200,000 values (the marker is left out and the page
says so; unlimited, it is drawn), and clipped layers pushed through
vello_cpu's RenderContext directly (hayro pushes no clipped layers). Each
case fails with only its own path's check removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 07:08:53 +08:00
mtlcl410 078acbc593 Merge remote-tracking branch 'upstream/main' into fix/form-fanout-budget 2026-11-07 07:03:01 +08:00
mtlcl410andClaude Opus 5.5 0647a4e724 fix(render): an unreadable CMap token counts once against the skip limit, with unit tests (hayro-cmap patch 3, hayro-postscript patch 2)
- `Scanner::parse_object_or_skip` skips whitespace and comments before
  taking its start offset, so a token after a space fails once, not
  twice, whoever calls it. (The CMap scan already skipped whitespace in
  `at_end`, so CMap results do not change.)
- Unit tests in hayro-postscript (stepping over a token, a failed read
  not read again, never past the end of the data) and in hayro-cmap
  (bfchar and bfrange after a stray `>` or a spaced name, the 1,024-token
  limit at its boundary, a CMap of unreadable tokens only failing).
- The render test checks a bfrange entry after the bad token, a run past
  the limit before the mappings, and a CMap of unreadable tokens only;
  its wall-clock assertion is gone, the no-re-read property is the
  deterministic hayro-postscript test.

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