Commit graph

1 commit

Author SHA1 Message Date
e0aab74452 feat(pdf): page operations — insert, reorder, duplicate, delete, import
Some checks failed
email.yml / feat(pdf): page operations — insert, reorder, duplicate, delete, import (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Phase 5's page management, matching dart-pdf's `page_ops_test.dart`,
`page_index_map_test.dart` and `import_source_test.dart`.

A reader sees "page 3"; the file holds a tree of /Pages nodes with /Kids
and /Count and /Parent back-pointers, any of which can be left stale. So
the writer **flattens to a single level**: a one-level /Pages node with
every page as a direct kid is valid, is what most producers emit, and
removes the entire class of bug where an intermediate node's /Count no
longer matches what is under it. Preserving an arbitrary tree shape
through arbitrary reordering is far more code for nothing a reader can
see.

`PagePlan` accumulates operations and applies them together, so
intermediate states never have to be valid — delete page 0 and insert a
new one at 0 without the document momentarily having no first page.
`PageIndexMap` reports where every page went, which is the only way to
fix an outline entry, named destination or link annotation afterwards.

What each operation carries matters and differs:

- Reorder and delete rewrite only the kid array, so page objects and
  their resources are untouched.
- Duplicate writes a new page dictionary that **shares** the original's
  resource references. Two pages naming one font object is normal;
  deep-copying would double the file and change nothing visible.
- Import must deep-copy the page and everything it reaches, renumbered,
  because source object numbers mean nothing in the destination. /Parent
  is deliberately not followed — it leads back to the source's page tree
  and from there to every other page in that file.

Inheritable attributes are resolved *before* a page is imported.
/Resources, /MediaBox, /CropBox and /Rotate may live on an ancestor
(Table 30) that is not coming with it, so a page imported without them
renders at the wrong size with no fonts, and nothing reports an error.

**Round-trip tested through the saved file**, which is Phase 5's exit
criterion: 20 tests that save, re-parse, and assert on what a reader
actually gets. Pages are identified by /MediaBox width rather than object
number, because object numbers are exactly what a page-tree bug
scrambles.

Mutation testing changed two things. Seven defects injected:

  /Count left stale                 1 fail
  /Count omitted entirely           1 fail
  imported /Parent not rewritten    1 fail
  inherited attributes not resolved 1 fail
  import does not deep-copy         3 fail
  duplicate loses /Contents         1 fail
  re-parenting skipped              1 fail

The last two only fail because of tests the mutations forced:

- **A stale /Count passed everything.** Our own parser walks /Kids and
  never reads /Count, so it cannot see the disagreement — but other
  readers trust /Count, and a document where the two differ opens with a
  different page count in different viewers. The test now reads the raw
  page-tree node instead of asking the document.
- **Re-parenting could be deleted with every test still green**, because
  the flat fixture's pages already parent to the root. Added a nested
  fixture with an intermediate /Pages node supplying an inherited
  /MediaBox — the case where leaving /Parent stale means a page keeps
  inheriting from a node it is no longer under.

Externally verified: a generated sample with pages swapped and duplicated
passes `qpdf --check` with no warnings, and poppler reads 4 pages with
the reordering visible in extracted text.

Engine suite 1039 -> 1075.

Remaining in Phase 5: flatten, object compaction, redaction, and outline
and struct-tree editing.
2026-08-17 10:38:55 +00:00