Commit graph

21 commits

Author SHA1 Message Date
a82c8f7ff7 feat(pdf): Unicode-aware search and layout-aware reading order
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (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
sms / gates (push) Has been cancelled
Phase 8 bullets one and two. Probing the existing code first, as the
workflow requires, found five defects rather than the one the plan names:

    SPLIT MATCH 'Hello': 0 hits
      plain_text: "Hello"
    PRECOMPOSED 'café': 0 hits
    COLUMNS plain_text: "LeftTopRightTop\nLeftBotRightBot"
    OUT OF ORDER plain_text: "second\nfirst"

`PageText::find` searched one run at a time and documented that as a known
limitation. It is a limitation from inside the code and a broken feature
from outside it: a writer starts a new run wherever it adjusts kerning, so
an ordinary word arrives as two runs, and the find bar says a word plainly
visible on the page is not there.

`search.rs` indexes the page as one flattened string with a map back to
(run, character), so a cross-run match is found and highlighted with one
rectangle per run — never a merged box, which across a line break covers
half the paragraph.

The separator between two runs is a geometric question with three answers:
abutting runs join with nothing (one word, split by kerning), separated
runs with a space, and a different line or column with a newline. The
newline matters as much as the empty join: joining lines with a space lets
"one Right" match across a column gutter, text that appears nowhere.
Whether two runs share a column is *asked* of the layout analysis rather
than re-derived, or the extracted text and the searched text disagree about
where a column ends — the original defect wearing a different hat.

NFD, never NFC: composition needs the next character, so an NFC fold
applied per character composes nothing and the two spellings of an accent
stay different. That was a real bug in the first draft. And case *folding*,
not lowercasing — Rust lowercases ß to ß, so "Strasse" never found
"Straße".

Columns are detected before lines, because two columns share their
baselines; that is what makes them columns. Bands are separated by a gutter
rather than by bare non-overlap, since two abutting runs on a line do not
overlap either.

Also fixed, found by running the gates rather than by looking: a stream
reader trimmed a trailing CR before `endstream` as if it were the writer's
separator. Binary data ends in CR about one time in 256, and when it did
the reader returned a stream one byte short — no longer AES-block-aligned,
so decryption produced garbage and Flate failed. Roughly one encrypted
document in 250 was silently corrupt on read. The test failed once under
coverage, passed five times in isolation, and failed 2 in 40 when actually
counted. A /Length consistent with the file is now the authority; both
stream readers are fixed and a test reads one file through each.

1477 tests pass (was 1426), coverage 88.37%, all floors met, external
readers pass. 10 mutations across the two modules, all killed.

ADR 0034.
2026-08-19 16:12:12 +00:00
1740da3f34 feat(pdf): render a form XObject to pixels — the golden caught what the
Some checks failed
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
assertions missed

`Rasteriser::register_xobject` takes a form's recorded commands from a
caller that can resolve the page dictionary, so Phase 7's last golden-corpus
criterion is met with pixels instead of with a request recorded by name.

The first golden of that page showed the form drawn at the **page origin**,
ignoring the `1 0 0 1 20 20 cm` that placed it. The recorded commands'
`SetTransform`s are absolute in form space, and replaying them overwrote the
page's CTM rather than composing with it. Nested lists now compose against
the CTM in force at the `Do`.

The colour assertions written next to that golden all passed while the bug
was live — a red square two pixels from where it belongs is still a red
square somewhere. That is the argument for pixel goldens in one sentence,
and it is why the golden is compared after the assertions and not instead
of them. The offset now has its own assertion too.

The new fixture's form deliberately overflows its own /BBox, so the clip is
visible in the golden as an absence rather than being taken on trust.

Phase 7's golden-corpus exit criterion is now met in full. The `ui.rs` smoke
tests remain blocked on the Makepad headless backend, as they have been
since Phase 1, and are still not claimed as done.

1426 tests pass.
2026-08-18 20:06:50 +00:00
d3089bc62a feat(pdf): nested content, the wire codec and tiled rendering — Phase 7 closed
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Three bullets, and the Phase 7 status table rewritten row by row.

**Nested content (ADR 0032).** A form XObject and a Type 3 glyph are the
same problem: a content stream inside a content stream. Both were parsed
completely and then not run. `paint_x_object` reported the name for "the
host" to resolve and no host existed, so `Do` painted nothing. Type 3 was
worse because it looked more correct — `d0`/`d1` reached the device, so the
pen advanced by the declared width and the page rendered an invisible line
of text with correct spacing after it.

`nested.rs` runs both, in pdf-graphics because the dependency runs graphics
→ document and this is the only crate that can see the interpreter and the
object model at once. Forms get their `/Matrix`, their `/BBox` clip and a
save/restore wrapper, because without the wrapper a form's colour leaks
into every object after it and looks like a bug in the document. Type 3
composes translate-then-matrix; the other order scales the translation and
puts the glyph at (1.7, 16.8) instead of (72, 700). Recursion is bounded in
both: unbounded, a self-referencing form is a stack overflow reachable from
an untrusted document, which is a denial of service and not a rendering bug.

**Wire codec and tiling (ADR 0033).** `worker.rs` moved interpretation off
the UI thread only because both ends shared a Vec. Tags are explicit
numbers, never declaration order, so reordering the enum cannot silently
make old recordings decode as different commands. Truncation is an error
rather than a short list — a decoder that stopped early would render a page
missing its last few operations, plausible and wrong.

The obvious truncation test failed, correctly: `Save` is one byte, so a cut
on a command boundary really is a complete list. It now tries every cut
position and requires each to be a named error or a genuine prefix.

Tile skipping is conservative. A command whose geometry is unknown is kept,
because dropping a state change corrupts everything after it in that tile,
silently. Only untransformed geometry that provably falls outside is
dropped. Every tile is asserted pixel-identical to that region of the
whole-page render: tiling that is fast and different is not an
optimisation.

Eight mutations across the two modules, all killed.

Phase 7 status is now two tables — the eight spec bullets and the exit
criteria — with what is missing named in the row rather than rounded up.
Three rows are not green: Makepad blend compositing needs render-to-texture,
the image-XObject pixel golden asserts the request rather than pixels, and
the `ui.rs` smoke tests remain blocked on the headless backend they have
been blocked on since Phase 1.

1425 tests pass, coverage 88.10% (was 87.60%), all floors met, external
readers pass.

ADRs 0032 and 0033.
2026-08-18 20:00:18 +00:00
f37197781e feat(pdf): glyph outlines from TrueType and CFF, and glyph-aware text runs
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
`sfnt.rs` read the metric tables and nothing else. It could say how wide a
glyph was and not what shape it had, so every renderer drew embedded text
with a substitute font at the correct advance — the failure mode that looks
most like success: the line breaks land right and the letterforms belong to
somebody else.

`outline.rs` returns one outline type for both formats. TrueType quadratics
are degree-elevated to cubics, which is exact, so no format detail leaks to
a consumer. Composite glyphs are placed by their offsets and scales, with a
depth bound because a font can reference itself. CFF Type 2 charstrings run
through an interpreter with biased local and global subroutines, hints,
hintmask byte counting, the leading width operand, and the FontMatrix as
declared rather than assumed to be 1/1000.

Separately, `ShowTextWithMetrics` carried one advance for a whole run —
enough to move the pen to the next run and nothing else. So `text.rs`
guessed: `seg.advance / char_count`. For "Wi" that puts the boundary
between the letters at 5 when it is at 9, and every caret, drag-selection
and search highlight in the application was wrong by that much for every
proportional font. `GlyphPlacement` now carries per-glyph pen offsets,
computed with the same expression as the run total so the two cannot drift.
The even-spacing fallback stays for fonts with no width table, which is
what `advance_is_measured` has always been for.

The fixture story is ADR 0029's, again. `cff_sample.otf` is a fontTools
conversion of DejaVu: no subroutines, no hints, no width operands. It
proved the interpreter draws the right shapes, and then four mutations of
that interpreter survived because nothing in the corpus reached the code
they broke — each of which produces a plausible wrong glyph from a font
that parses. `cff_subrs.cff` is hand-assembled for exactly those four, and
fontTools agrees with every expectation asserted against it. A fifth
mutation survived a composite test that counted contours; it is killed now
by one that measures where the components land.

Coordinates are asserted against fontTools ground truth, not against our
own output. Seven mutations, all killed. 1397 tests pass.

Deferred and recorded, not claimed: CID-keyed CFF, `seac` accents,
rendering outlines through the Makepad device.

ADR 0031.
2026-08-18 19:44:18 +00:00
0bef30a6d5 feat(pdf): compositing and overprint — the blend maths had no backdrop
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
`transparency.rs` implemented all sixteen blend modes and unit-tested them
against the specification's formulas. Nothing ever called them with a
backdrop. The Makepad renderer's `SetBlendMode` pushed a
`TransparencyError::Unsupported` and then painted the source colour, so a
/Multiply highlight and a /Normal one produced byte-identical output and
every test passed — because every test asked "was the right command
issued", not "does the page look right".

Overprint had no code at all. /OP, /op and /OPM were not parsed, so an
overprinting object knocked out the inks under it. That is not a missing
feature, it is the inverse of the instruction: on a press it is the
difference between a colour and a hole.

- `composite.rs`: a straight-alpha RGBA `Canvas` implementing §11.3.6's
  union formula, weighted by backdrop alpha so a Multiply over transparency
  is the source rather than black. Constant alpha and per-pixel soft masks.
  Transparency groups composite as a unit; knockout groups are refused by
  name rather than silently treated as non-knockout.
- Overprint as `composite_cmyk`, separate from the RGB path rather than a
  flag on it: overprint is a statement about inks and RGB has none. /op
  defaults to /OP per table 58 — defaulting it to false makes the common
  `<< /OP true >>` knock out every fill. §10.7.5's "no effect on an RGB
  device" is asserted, so our doing nothing there is the spec rather than
  an omission.
- `raster.rs`: a CPU rasteriser that replays a command list onto a canvas.
  Not on the display path, no anti-aliasing, no fonts; it exists so
  compositing has a verifiable output. In pdf-graphics and not pdf-makepad
  because a test that needs a GPU is a test that does not run.
- Golden **pixels** for shading, mesh, blend and overprint pages — Phase
  7's exit criterion, which the Phase 2 command-text goldens cannot meet.
  ASCII grids with a colour legend, quantised to quarter steps; each test
  asserts its exact colours before comparing, so a wrong-but-stable render
  cannot be blessed by an UPDATE_GOLDEN run.

Six mutations, all killed, including the two that describe the old
behaviour: discarding the blend result, and ignoring the overprint flag.

1364 tests pass. ADR 0030.
2026-08-18 19:25:15 +00:00
728fbc3ad0 fix(pdf): mesh shadings — three bugs in code that had no fixture
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
ADR 0028 shipped types 4-7 and said honestly that they were unproven: the
uncovered lines of `shading.rs` were exactly `parse_mesh`, "the position
`image.rs` was in before ADR 0016 found the JPEG decoder was a stub".

Writing the fixtures found three real bugs.

- Type 5 has no per-vertex flag; `/VerticesPerRow` delimits it. Reading 8
  phantom bits shifted every vertex after the first, decoding plausible
  coordinates that were entirely wrong.
- Types 6 and 7 are patches: 12 or 16 control points carrying no colour,
  then four corner colours. The old loop read a colour per point, consumed
  three times too many components, ran off the stream, and the
  None-on-truncation path swallowed it as "the mesh ended".
- A flag-0 triangle is three vertices whose second and third flags are
  ignored (§8.7.4.5.5). Acting on them cleared the strip every time and
  produced no triangles at all. Caught in new code, before it shipped.

And one omission: `color_at_point` returned None for a mesh, so a mesh that
parsed perfectly still painted nothing — indistinguishable from one that
failed. `MeshTriangle::color_at` now interpolates the corner colours by
barycentric coordinates, None outside, because black is a colour a mesh can
legitimately produce.

Shared-edge patches (flags 1-3) inherit the previous patch's edge rather
than being read as fresh patches, which desynchronised the rest of the
stream.

Five corpus fixtures, generated from named coordinates and colours so every
expected value in the tests is one the generator wrote deliberately. Eight
tests, five mutations, all killed. Coons flattening is still an
approximation and still reports `is_approximate`.

ADR 0029.
2026-08-18 19:10:49 +00:00
c1d1e67f3a feat(pdf): shadings — the sh operator was parsed and thrown away
Some checks failed
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
ADR 0028, the first of Phase 7's eight bullets.

content.rs contained `PdfOp::Shading(_name) => {}`. The operator was lexed,
given its own variant, matched during interpretation, and discarded. A page
whose background is a gradient rendered as nothing.

Nothing caught it for the usual reason: a blank region is a legal thing for
a page to contain, so "drew nothing" and "drew what was asked" are
indistinguishable without an assertion naming the expected colour. The
golden corpus had no shading page, so there was nothing to be wrong.

Two of the three pieces already existed — function.rs evaluates the colour
function and colorspace.rs converts it to RGB. What was missing was the
geometry between them.

Sampling rather than a gradient primitive: a PDF shading is defined by an
arbitrary function, possibly a sampled table or a PostScript program, and
neither reduces to a stop list without loss. A device with a native
gradient can still recognise the two-stop case from the samples.

"No colour here" is None, not black. Black is a colour a shading can
legitimately produce, so returning it for "outside an unextended shading"
would paint a rectangle the author never asked for and the caller could not
tell the two apart.

Types 1-5 exact. Coons and tensor patches are flattened to their corners,
which loses the curvature, and is_approximate says so rather than leaving a
caller to assume fidelity. An unknown type is refused by number: a mesh
drawn as a flat fill is a plausible-looking wrong answer.

paint_shading is a new trait method, so the compiler found every
implementor. The Makepad renderer records the request in pending_shadings,
mirroring pending_xobjects — it cannot resolve a /Shading resource because
it does not own the page dictionary, and recording the request is what
stops the operator vanishing a second time. That holds even for types we
refuse, so a host can warn the user.

Four mutations, all killed. The first — discarding sh again — fails three
tests.

Stated plainly and left unticked: the mesh path is written but NOT
exercised by any real stream. shading.rs is at 68% and the uncovered part
is exactly parse_mesh and triangulate. Mesh support should be treated as
unproven, not working: the code runs and produces triangles, and nothing
yet demonstrates they are the right triangles. That is the position
image.rs was in before ADR 0016 found the JPEG decoder was a stub.

The Phase 7 status line is a table from the start this time — one row per
spec bullet, seven of them saying "not started". Per ADR 0021, written
before the work rather than after it.

pdf: 1321 passed (was 1291). pdf-ui: 1366. Coverage 87.60%, floors met.
2026-08-18 17:30:39 +00:00
1220f89fc6 feat(pdf): close the last three Phase 4 items — reconciliation, CFF, cmap
Some checks failed
email.yml / feat(pdf): close the last three Phase 4 items — reconciliation, CFF, cmap (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
The three items the previous commit's audit found unimplemented while the
status line said "complete". All three are done and externally verified.

**1. Field-value reconciliation** (`reconcile.rs`).

A field carries its value in /V and its rendered look in /AP, and nothing
in the format keeps them in step. Files arrive with them disagreeing all
the time: a producer writes /V and leaves appearances to the viewer, or
something edits /V without touching /AP. Until now this crate simply
believed /V and regenerated appearances only for fields it had itself
edited — right for a field we changed, wrong for a field that arrived
inconsistent.

The module deliberately does **not** pick a winner. PDF 32000-1 §12.7.3.3
settles exactly one case — /NeedAppearances true means /V is authoritative
— and is silent on the other, where a conforming viewer renders /AP and
never looks at /V. So it classifies the disagreement and resolves it
against a caller-declared `Intent`, because the right answer genuinely
differs: a viewer must show /AP to match other viewers, an extractor must
read /V, an editor must regenerate so the saved file agrees with itself.
Silently choosing one would be ADR 0017's failure in a new place — every
answer plausible, none checkable, the caller unaware a decision was made
for it.

Two cases are not judgement calls and are handled outright. A missing or
dangling appearance renders *blank*, and blank is never what the producer
meant, so even Display regenerates. An unselected radio member showing
/Off while the group's /V names another member is correct, not a
conflict — reporting it would flag every well-built radio group there is.

**2. Type1/CFF embedding** (`embed_opentype_whole`).

The spec says "if feasible". Subsetting CFF is not — it means rebuilding
the CFF INDEX, charset and charstrings, a second font format inside the
first — and `subset_truetype` rightly keeps refusing it by name. Embedding
the program *whole* is feasible, and that is what this does: /FontFile3
with /Subtype /OpenType under a CIDFontType0 descendant, per Table 126.

Each of those keys matters and none is guessable from the others. A CFF
program in /FontFile2, or under a CIDFontType2 descendant, still produces
a file qpdf accepts and a font that loads as the wrong type or not at all.
/CIDToGIDMap is omitted because it is defined for CIDFontType2 only.

The trade is made visible rather than buried: `EmbeddedFont::is_subsetted`
is false here, so a caller with a size budget — or a licence that forbids
shipping a whole face — can refuse instead of discovering it from the
output size.

**3. `repair-cmap`** (`glyph_index`).

A symbol font declares no Unicode subtable: it maps glyphs into the
private-use area at 0xF000 + the low byte under platform 3, encoding 0.
Asking it for 'A' found nothing and the character silently vanished from
the output — the font "missing" a glyph it plainly has. Now the (3,0)
subtable is kept as a fallback and retried at 0xF000 + low byte, after the
proper lookup fails so a font with both subtables is still read through
the Unicode one. Format 0 is read too; omitting it left legacy and symbol
fonts mapping nothing while appearing to have a usable cmap.

The repair must not manufacture glyphs, which is its own test: a character
the font genuinely lacks still returns None, because turning a missing
character into a wrong one is worse.

**Fixtures.** No CFF or symbol font ships on the CI image, and neither can
be tested honestly against a hand-built stub — the point is that the bytes
are a font program a third-party reader accepts. Both are generated from
DejaVu by checked-in fontTools scripts: `cff_sample.otf` (1.6 KB, real
OTTO/CFF outlines) and `symbol_sample.ttf` (664 B, a single (3,0) subtable
so the repair path is the only route to its glyphs).

Both generators pin `head.created`/`head.modified` to zero. fontTools
stamps the current time, so the output differed on every run and CI's
"fixtures match their generator" check failed against a file nobody had
edited. Caught by running that check rather than assuming it passed. A
fixture that cannot be regenerated byte-for-byte is not reviewable: you
cannot tell a deliberate change from a rebuild.

**Verified by mutation**, seven injected defects, each confirmed red:

  NeedAppearances ignored              1 fail
  dangling /AS not detected            1 fail
  blank rendering shown faithfully     1 fail
  CFF written to /FontFile2            1 fail
  CFF given a CIDFontType2 descendant  1 fail
  whole font claims to be subset       1 fail
  cmap 0xF000 retry removed            3 fail

**Verified externally.** The sample now carries a third page set in the
whole-embedded CFF font, and `check-pdf-external-readers.sh` gained
`pdffonts` — the only check that inspects a font *program* rather than the
file structure, which is exactly where a wrong /FontFile key shows up.
poppler reports both fonts embedded and distinguishes them correctly:

  ETXLDI+DejaVuSans   CID TrueType      Identity-H   emb yes  sub yes
  NigigTestCFF        CID Type 0C (OT)  Identity-H   emb yes  sub no

and extracts "Hello CFF 123", which only works if the CFF program loaded,
/Identity-H addressed its glyphs and /ToUnicode mapped them back.

That check also caught its own page-count assertion going stale when the
third page landed — a gate that notices its own fixture changing is
working.

Engine suite 953 -> 985. Coverage 87.27%, all floors met.

Phase 4 is complete but for the ui.rs interaction tests, which are written
and blocked on the Makepad fork's missing headless backend.
2026-08-17 09:31:41 +00:00
89ca5186c6 docs(pdf): Phase 4 is not 100% — audit it, and verify the half that is
Some checks failed
email.yml / docs(pdf): Phase 4 is not 100% — audit it, and verify the half that is (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
Asked whether Phase 4 was complete, I checked the tree instead of my own
commit message, and the commit message was wrong.

Three items named in the Phase 4 spec are **not** implemented, and the
status line said "complete" over them:

- **Field-value reconciliation** (`form_reconcile_test.dart`). Setting a
  value writes /V, marks the field dirty and regenerates /AP — that all
  works. What is missing is the reconciliation case: a file opened with
  /V and /AP *already disagreeing*, where the right answer depends on
  /NeedAppearances. Nothing decides that today.
- **Type1/CFF embedding.** The spec hedges with "if feasible", so this is
  a legitimate deferral rather than an oversight — but "complete" did not
  say so. `sfnt.rs` detects CFF outlines and `font.rs` reads an existing
  /FontFile3; nothing writes one. Creation is TrueType-only.
- **`repair-cmap`.** No equivalent exists.

`text_box_appearance_test.dart` *is* covered, by appearance.rs:235 — it
just does not carry that filename, which is why a grep for the dart test
names is a starting point and not an answer.

The other half of the exit criterion — "generated PDFs open cleanly in
external viewers" — had never been checked at all. The sample generator's
own doc comment admits no test in this repository can assert it. So I
ran it through implementations we share no code with, and **it passes**:

  qpdf --check           no syntax or stream encoding errors
  pdfinfo                title, author, subject, keywords, 2 pages,
                         Form: AcroForm
  pdftotext              all text, including the embedded DejaVu subset
                         and its em-dash
  qpdf --list-attachments  readme.txt, extracted by name with description
  catalogue              /Outlines /Names /EmbeddedFiles /PageLabels
                         /Dests /PageMode /ViewerPreferences /AcroForm

`tools/check-pdf-external-readers.sh` makes that repeatable, and pdf.yml
runs it. It treats a qpdf *warning* as failure, not just an error: qpdf
warns where it had to reconstruct, and reconstructing is exactly what a
stricter viewer will refuse to do. Negative-tested twice — removing the
attachment fails 3 checks, and corrupting the startxref offset makes
qpdf report "file is damaged".

Two defects that audit found:

- **The sample never exercised XMP**, so the Phase 4 feature most likely
  to be silently missing was also the one nothing looked at. Probed
  separately: `set_xmp_metadata` works, pdfinfo reports
  `Metadata Stream: yes`.
- **A `Banner` naming an unregistered font produces a structurally valid
  PDF that renders no text.** qpdf --check passes; poppler says
  `Unknown font tag 'F1'` and draws nothing. `stamp.rs` cannot register
  the font itself — fonts belong to the document, and a banner does not
  know which document it will be drawn into — so this is now documented
  on `Banner` with a worked example, and pinned by
  `a_banner_font_must_be_registered_or_the_page_lacks_the_resource`,
  which asserts on the page's /Font resources because that is the thing
  actually missing and the thing a caller can check.

The plan now records that it was wrong once, rather than quietly
correcting itself. A status line that has been overstated should show its
working.

Engine suite 952 -> 953. Phase 4's engine half is verified end to end
against third-party readers; the ui.rs interaction half is written and
still blocked on the Makepad headless backend.
2026-08-17 09:11:03 +00:00
8701f5df51 feat(pdf): image embedding and header/footer stamping — Phase 4 complete
Some checks failed
email.yml / feat(pdf): image embedding and header/footer stamping — Phase 4 complete (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
The last gap in Phase 4: dart-pdf's header_footer_test, image_stamp_test
and image_pdf_test had no counterpart here.

What was missing is worth stating precisely, because it is the shape of
bug ADR 0017 exists to catch. ContentWriter::draw_image has emitted
`q w 0 0 h x y cm /Name Do Q` since Phase 2, and was tested. But nothing
in the stack could *create* the image XObject that /Name resolves to. So
every Do operator ever written named a resource that did not exist, no
document could contain a raster image, and nothing anywhere returned an
error. The writing half was present, the reading half faithfully
reported the content stream, and the image was simply never there.

stamp.rs adds: image XObject embedding, header/footer banners with
left/centre/right alignment, image stamp content, and stream
composition. A JPEG is embedded as-is with /DCTDecode — PDF's image
model is the same DCT data the file already holds, so re-encoding would
lose quality for nothing — and its geometry is read from its own SOF
marker rather than trusted from the caller, because a /Width that
disagrees with the codestream renders as diagonal garbage in every
viewer. Raw samples embed as Flate.

Embedding an image then adding the page that draws it exposed a live
defect in PdfDocBuilder. add_object derived its number from
`3 + 2 * pages.len()`, so every add_page after an add_object silently
shifted a number already handed out. Embedding an image and then adding
its page — the natural order, since the page's content stream has to
name the image — produced a page whose /XObject entry pointed at the
page object itself:

  3 0 obj <</Type /Page ... /XObject <</Im0 3 0 R>>>>

The file parsed. The reference resolved. The resource was the page.

This is the same positional-numbering defect already fixed once for
fonts, one layer out — the comment above first_extra_object_number
describes the font version, where /ToUnicode pointed at the descriptor
and /FontFile2 at the Type0 wrapper. Both come from deriving object
numbers from collections that are still growing. Fixed at the root: the
page count is frozen when the first extra number is issued, and pages
added afterwards are allocated past the fixed block instead of
colliding with it. Non-contiguous page numbers are legal — /Kids is an
explicit array — and 952 tests confirm nothing depended on the order.

The integration tests parse the generated file back with PdfDocument and
assert the image appears in `page.xobjects` with subtype Image, that its
/Width and /Height match the SOF marker, and that the header and footer
baselines are at opposite ends of the page. Reading the resource back is
the assertion that matters: a substring check for "/Im0 Do" passed
throughout the entire period when no image could be embedded at all.

Verified by mutation, five injected defects, each confirmed red:

  numbering fix reverted        4 fail
  JPEG width/height transposed  5 fail
  header positioned from bottom 3 fail
  sample-count check removed    1 fail
  attach_image_to_page a no-op  5 fail

One test needed correcting rather than the code: three assertions
grepped the output for operators, which are Flate-compressed by default,
so they were asserting against compressed bytes. They now disable
compression explicitly — the structure is identical either way, and the
alternative was a test of miniz_oxide.

Engine suite 920 -> 952. Coverage 86.16% -> 86.40%; stamp.rs at 94.64%
with a floor at 90.

Phase 4 is complete and the plan records it, including the numbering
defect, since a status table that lists only features would not have
told the next reader why the object numbers look the way they do.
2026-08-16 22:27:32 +00:00
674b2be66d feat(pdf): JPEG 2000 decoding — Phase 3 complete, all three codecs
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
email.yml / feat(pdf): JPEG 2000 decoding — Phase 3 complete, all three codecs (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
The last codec ADR 0015 deferred. The plan recorded the blocker as a
dependency decision, not an algorithm: openjpeg would add a C dependency
that breaks the Android cross-compile. This is pure Rust and adds no
dependency at all.

It shares the MQ arithmetic decoder with JBIG2 — T.800 and T.88 specify
the same coder — so the previous tranche paid for most of this one.
Context::with_state moved onto the shared type because JPEG 2000 starts
three of its nineteen contexts away from state 0 and JBIG2 starts all of
them at 0.

Implemented: codestream and JP2 container parsing, packet headers with
tag trees and the bit-stuffing rule, EBCOT tier-1 (all three passes,
four zero-coding context tables, run-length mode), both 5/3 reversible
and 9/7 irreversible wavelets, RCT and ICT, arbitrary decomposition
levels, and multiple components.

Refused by name: multiple tiles, custom precinct partitions, code-block
style options, COC/QCC/RGN/POC overrides, subsampled components. Each
error says which feature the file needs. This matters more here than
anywhere else in the stack, because a JPEG 2000 decoder that quietly
skips something does not fail — it returns a slightly soft or banded
image that looks entirely fine.

That property also dictates how this is tested. Fixtures are produced by
OpenJPEG via Pillow and compared **exactly**, sample for sample: the
fixtures are lossless 5/3 so no tolerance is needed, and a tolerance is
where a subtly wrong decoder hides. Four images — grayscale raw
codestream, the same in a JP2 container, a larger one whose tag trees
actually branch, and RGB. A generator script is checked in beside them
so CI can prove the fixtures still match what produced them.

Verified by mutation. The first round was misleading and is worth
recording, because it is the same lesson as ADR 0017:

  DC level shift dropped        3 fail
  5/3 lifting rounding changed  2 fail
  RCT sign flipped              PASSED  <- survived
  RCT components swapped        PASSED  <- survived
  cleanup run-length disabled   PASSED  <- survived
  sign-context XOR dropped      PASSED  <- survived

Four mutations survived because Pillow writes MCT=0 by default, so the
RGB fixture coded its three components independently and never reached
the colour transform at all. The RCT branch was completely untested
while appearing covered — an untested branch that looks tested is worse
than one that looks missing. Added rgb8_mct.j2k with mct=1; all four
now fail. The header bit-stuffing mutation is caught by the unit test
rather than the round-trip.

Two real defects found while writing the tests:

- A corrupt marker length in a tile-part header walked the read cursor
  past the codestream and panicked on a slice. Found by the corruption
  sweep, not by review. The sweep now truncates at every length and
  flips every byte of a real file, and asserts only that nothing panics.
- The 9/7 flat-signal test initially asserted an amplitude I had derived
  from my own arithmetic. That is a test agreeing with the code by
  construction. It now asserts flatness — a ripple means the lifting or
  the edge extension is wrong — and the amplitude is pinned by the
  OpenJPEG round-trips instead, which use pixels this code did not
  produce.

Also removed two dead fields and an unused parameter that clippy found:
Subband::x0/y0 are always zero in the single-tile case this supports,
and dead state implying multi-tile support exists is worse than no
state.

JPX decodes on the image path, like JBIG2, because the codestream
carries its own geometry; it stays in REFUSED_CODECS with a reason
string saying where it is decoded rather than that it is missing.

Engine suite 866 -> 920. Coverage 85.66% -> 86.16%; jpx.rs at 93.72%
with a floor at 88.

Phase 3 is complete: CCITT, JBIG2 and JPX all land, and the plan is
updated to say so and to record how the two gating questions — JBIG2's
CVE record and JPX's C dependency — were actually answered.
2026-08-16 22:17:40 +00:00
81e846ae35 feat(pdf): JBIG2 generic-region decoding, and the bitonal image path
Some checks failed
email.yml / feat(pdf): JBIG2 generic-region decoding, and the bitonal image path (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
The second codec Phase 3 deferred. ADR 0015 made a threat review the
precondition for implementing JBIG2 rather than an effort estimate, so
the review's conclusion is encoded in what this does and does not do.

What is implemented: the MQ arithmetic decoder (T.88 Annex E), generic
region decoding with templates 0-3 and AT pixels, TPGDON typical
prediction, and MMR-coded regions. Segment header and region info
parsing, and page composition.

What is refused, by name: symbol dictionary, text region, halftone
region, refinement region, and a non-empty /JBIG2Globals. Those are the
segment types that carry the composition machinery, and shipping them
means shipping an interpreter over untrusted input — it is what
FORCEDENTRY built its computer out of. A file needing them gets a typed
error naming the segment type, exactly as the whole codec used to.

The MQ coder itself is pure arithmetic with no file-controlled
addressing, which is why it is safe to run and the composition parts are
not. Every bound is checked against the declared region size before a
buffer is indexed: region dimensions against MAX_DIMENSION and a pixel
budget before allocation, segment lengths against the remaining stream,
and the region's declared position against the page before a single
pixel is written. That last one is the format's actual exploit surface
and it has its own test saying so.

MMR regions delegate to ccitt.rs rather than carrying a second G4
decoder, so the two cannot drift apart. A test decodes the same coded
bits through both paths and requires identical pixels — that is what
catches an inverted convention, and JBIG2 is natively 1=black where PDF
is 0=black, so the inversion is real and easy to get backwards.

JBIG2 is decoded on the image path, not in the filter facade, because it
needs /Width and /Height from the image dictionary. It therefore stays
in REFUSED_CODECS with a reason string that says where it *is* decoded,
so a host showing that string does not tell a user the codec is missing
when it is not. CCITT moved the other way for the same reason inverted:
it derives its dimensions from /DecodeParms, so it decodes in the facade.

Wiring both into ImageInfo::decode_to_rgba surfaced a defect in the
parallel-array rule that the CCITT tranche had not reached. For
/Filter [/FlateDecode /CCITTFaxDecode] the /DecodeParms array has one
entry per filter, and the obvious implementation takes arr[0] — handing
the Flate parameters to the fax decoder. ccitt_parms_of finds CCITT's
own index instead. This is the same bug ADR 0015 records for the old
chain code, in a new place.

A declared-but-unresolved /JBIG2Globals returns None rather than
decoding without it. Decoding anyway yields a blank or partial image
that every caller reads as a success — the declared-versus-delivered
failure of ADR 0017.

Verified by mutation, six injected defects, each confirmed red:

  compose bounds check removed        1 fails
  pack() stops inverting              4 fails (both suites)
  globals silently ignored            1 fails
  refused segments silently skipped   1 fails
  declared-globals check dropped      1 fails
  ccitt_parms_of always takes slot 0  1 fails

29 unit tests and 12 integration tests, asserting pictures rather than
buffer lengths. ADR 0016's stub JPEG decoder returned a correctly sized
black rectangle and passed everything that checked a length; these say
which colour they expect.

Engine suite 825 -> 866. Coverage 85.15% -> 85.66%; jbig2.rs at 94.84%
with a floor at 90, and image.rs 31.76% -> 44.77% so its floor rises
28 -> 40.

JPX remains refused and is the next tranche.
2026-08-16 22:02:26 +00:00
7d6fc4cbbe feat(pdf): document creation — outlines, forms, attachments, font subsetting
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Phase 4 of NIGIG_PDF_FEATURE_PARITY_PLAN.md. ADR 0019.

Almost none of it existed: Outlines, PageLabels, EmbeddedFiles and
ViewerPreferences appeared nowhere in the workspace, in any crate. What did
exist was a builder whose central method was

  pub fn add_page_with_content(&mut self, _width: f64, _height: f64, ...)

which accepted a page size and discarded it. Asking for 200x400 and 300x500
gave two US Letter pages, because no /MediaBox was written at all. The test
asserted the output contained the string "/Type /Page", which it did.

Two more defects sat in the object writer, both producing files our own
parser rejects: dictionary keys were written unescaped (a key with a space
reparses as "expected number"), and f64::NAN was emitted as the literal
token NaN, so one non-finite value anywhere made the document unreadable.

Added: outline trees with the open/closed state in the sign of /Count,
/PageLabels as a number tree with real roman and A..Z/AA..ZZ numbering,
named destinations, attachments with file specs, /Info, XMP, viewer
preferences, page mode and layout; AcroForm creation for text, checkbox,
radio, choice and signature fields with generated appearances; and
TrueType subsetting - DejaVu Sans goes from 759,720 bytes to 4,348 for
twelve characters.

cmap is deliberately not rebuilt: the subset is embedded as a CID font with
Identity-H, so the content stream addresses glyphs by id and /ToUnicode
serves extraction. A cmap disagreeing with the content stream is worse than
none. CFF is refused by name rather than emitting a font with no glyphs.

Nine real bugs, every one found by running the output through an
independent tool rather than by reading the code:

  1 page size discarded              reading a generated file back
  2 dict keys unescaped              probing the writer
  3 NaN written as a keyword         probing the writer
  4 subset zeroed the lsb            fontTools outline compare
  5 hmtx indexed by new gid          fontTools outline compare
  6 name table format read as count  BaseFont came out "Embedded"
  7 add_font shifted numbers already handed out
  8 trees allocated over font numbers - object 29 written twice
  9 widgets missing /F Print, /P and appearance /Resources

7 and 8 are the instructive pair: every reference resolved and every object
existed, each simply named the wrong thing. pypdf reported correct field
values from a file PDFium rendered blank. 9 is the one only a renderer could
find - /F defaults to non-printable, and a form XObject naming a font its
/Resources does not declare is discarded whole.

Verified by three independent implementations: fontTools (0 outline
mismatches of 12 against the source font), pypdf (metadata, page sizes,
outline with resolved page numbers, all five fields, attachment
byte-for-byte, labels ['i','1']) and PDFium, which renders both pages
correctly. cargo run -p nigig-pdf-graphics --example generate_sample
regenerates the sample.

Fourteen mutations. Three survived and each exposed a weak test: the key
test used an attachment name (written as a string, never a key), nothing
read the outline open state, and /P could not be witnessed because
page_index is supplied by the reader, which already knows the page. All
three now killed.

pdf: 789 passed (was 730). pdf-ui: 775. Coverage 85.17%.
2026-08-16 21:02:19 +00:00
6d2e3fb696 fix(pdf): repair the tree a hand-resolved merge left red
Some checks failed
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
Commit 258fa32 ("Merge origin/main: resolve xref/document conflicts") is not
a merge - it has one parent - and it landed a PDF tree that does not pass
its own gates. Found by running the suite, not by reading the diff.

Four separate breakages:

1. xref_streams::a_wrong_type_at_startxref_is_named_not_guessed FAILED.
   The conflict resolution replaced the diagnostic error with a generic
   "expected xref or cross-reference stream". The fixture writes
   /Type /Frobnicate and the parser has that name in hand; throwing it away
   leaves the reader knowing only that this is not what we wanted, which is
   the least useful half of the story. Restored, and extended to name a
   stream with no /Type at all. This is an ADR 0013 merge criterion.

2. golden_render::golden_text_line_ops FAILED - the merge added the test
   but not its golden file. Verified the output by hand against the spec
   before blessing it: leading 14, lines at 700/684/670/656/642, which is
   correct for Td/TD/T*/'/". A golden file blessed without reading it locks
   in whatever bug exists.

   That golden cannot witness the " operator's two spacing operands: word
   and character spacing live in the graphics state and emit no render
   command, so dropping both would leave the file byte-identical. Added a
   test that asserts the state directly. Mutation-checked: removing the two
   set_*_spacing calls fails it, and leaves the golden untouched.

3. clippy -D warnings failed with 6 errors on pdf-cos and pdf-document, so
   CI's engine job could not have passed. Two unused imports, a dead
   read_be (superseded by the `field` closure in parse_xref_stream), a
   collapsible if-let, and - committed into the source -

     // ... keep the entire top of the file unchanged until the resolve_num function ...

   an editing instruction left in document.rs as a doc comment.

4. cargo fmt --check failed on document.rs and xref.rs.

Mutation-checked both fixes: reverting the xref message fails 1 test,
dropping the " spacing operands fails 1.

pdf: 730 passed. pdf-ui: 775 passed. Coverage 84.15%, floors met;
destinations.rs holds at 98.65%.
2026-08-16 20:09:48 +00:00
258fa3259e Merge origin/main: resolve xref/document conflicts, add makepad_table
Some checks failed
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
2026-08-16 22:53:23 +03:00
6a18886185 feat(pdf): Type 3 fonts and streaming interpretation — Phase 2 complete
Some checks failed
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
The last two items of NIGIG_PDF_FEATURE_PARITY_PLAN.md Phase 2. Design and
merge criteria in REVIEWS/adr/0014-pdf-type3-fonts-and-streaming.md.

TYPE 3 FONTS DREW NOTHING

A Type 3 font's glyphs are not outlines - they are content streams, listed
in /CharProcs and mapped to text space by /FontMatrix. Probing a document
with one:

  fonts on page: ["T3"]
    T3: subtype=Type3 base=Unknown
  -> are the glyph procedures reachable? no CharProcs field exists
  -> is /FontMatrix exposed?             no field exists

The font was detected and then nothing could be done with it. /CharProcs and
/FontMatrix appeared nowhere in the crate, so the procedures were unreachable
and the text was silently invisible - a page that renders, reports no error,
and is missing content.

New pdf-document/src/type3.rs parses /FontMatrix, /CharProcs, /Differences,
/Widths, /FontBBox and the font's own /Resources, and resolves a character
code to its glyph procedure's decoded bytes. /FontMatrix is applied as
written rather than assumed to be the common 0.001 scale - Type 3 fonts
routinely use other matrices, which is the point of the entry. A missing
/CharProcs entry is a typed error naming the glyph, not a blank.

A THIRD BUG, FOUND WHILE WIRING d0/d1

The interpreter parsed both operators and discarded them:

  PdfOp::Type3Width(_wx, _wy) => {}
  PdfOp::Type3BBox(_x1, _y1, _x2, _y2) => {}

They are how a Type 3 glyph declares its advance, so even a renderer that
could draw the glyphs would stack them all at one point. Wiring them to the
device exposed that `d1` takes SIX operands - wx wy llx lly urx ury - and the
parser read four, so the "bounding box" was really the advance and the
advance was lost entirely. Now `Type3BBox { wx, wy, bbox }`, reading all six.

STREAMING INTERPRETATION

parse_content_stream materialised every operator into a Vec before
interpreting any of them: peak memory proportional to the whole content
stream, on a stream walked once and discarded. Adds ContentStreamIter and
interpret_streaming, with parse_content_stream reimplemented on top of the
iterator so there is ONE tokeniser rather than two that can drift.

Equivalence is proven, not asserted: a test compares both paths across every
corpus fixture, and a streaming_interpreter fuzz target compares them over
arbitrary bytes, which is where a divergence would actually hide.

4 corpus fixtures, 13 acceptance tests, 9 unit tests. Mutation-checked:
reverting d0 to a no-op fails glyph_advances_reach_the_device.

Phase 2 is now complete; the plan is updated with an item-by-item audit.
Several entries were already done (inline images, Do, text state, shading);
the plan's "biggest gap" was xref streams, closed in ADR 0013.

TEST_TARGET=pdf 615 -> 637, TEST_TARGET=pdf-ui 660 -> 682.
rustfmt and clippy -D warnings clean.
2026-08-16 17:28:44 +00:00
a994213e8b feat(pdf): tagged structure tree, and the BMC bug that turned red green
Some checks failed
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 8 #7 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md ("PDF/UA
structure tree"). Design and merge criteria in
REVIEWS/adr/0011-pdf-structure-tree.md.

Investigating the accessibility gap surfaced four defects in the
marked-content operators the structure tree depends on. The first is not
an accessibility problem at all.

1. BMC never parsed, and corrupted the colour of everything after it.
   The dispatcher matched b'B'+b'M' only when the third byte was a
   delimiter; BMC's third byte is 'C', so the arm never fired. Because
   the operator was never recognised it never CLEARED ITS OPERAND, and
   the leftover /Tag shifted the operands of whatever came next:

     1 0 0 rg            -> RgbFill(1.0, 0.0, 0.0)   red
     /Span BMC 1 0 0 rg  -> RgbFill(0.0, 1.0, 0.0)   green

   Tagged documents are precisely the ones containing BMC, so the
   documents that tried hardest to be accessible rendered wrong colours.

   This is the fifth instance of the operator-shadowing family already
   fixed for cm, rg, gs, b/b* and end-of-stream text operators. ADR
   0009's every_multi_char_operator_parses_as_itself test exists to stop
   exactly this - and would have caught it, except BMC was one of two
   operators excluded from its table as "genuinely unimplemented".
   Excluding a known-broken operator from the test whose job is finding
   broken operators is how it survived. The exclusion list is gone.

2. BDC discarded its property list, which carries /MCID - the only link
   between a run of page content and the structure element describing
   it. Without it a tree can be parsed but never attached to anything.

3. The op produced by BDC was named MarkContentBmc, and BMC produced
   nothing. The names were the wrong way round, which is how the missing
   arm survived review: the enum looked like it had a BMC case.

4. Found while fixing 2: the content lexer had no dictionary support at
   all. It read `<` as a hex string without checking for a second `<`,
   so `<</MCID 0>>` parsed as the string "0C0D0". Content streams now
   parse direct objects properly, bounded at 16 levels; a single `<` is
   still a hex string and a test pins that.

On top of that, new pdf-document/src/structure.rs: /StructTreeRoot,
/StructElem trees, /RoleMap resolution, depth-first reading order,
/Alt, /ActualText, /E, /Lang, and MCID-to-element lookup. Cycles in /K
are cut at the first repeat and reported by object number rather than
expanded to the depth bound.

Accessibility findings are mechanical checks reported as findings, NOT a
conformance verdict: there is no is_pdf_ua and no ComplianceReport, and
a mutation-checked tripwire test fails if either appears. Real PDF/UA
conformance needs human judgement - whether /Alt text is accurate is not
mechanically decidable - so claiming it from six checks would be exactly
the overclaim this codebase keeps removing.

7 corpus fixtures, 14 acceptance tests, 22 unit tests, and a
parse_content_dict fuzz target for the new object parser.

TEST_TARGET=pdf 536 -> 575, TEST_TARGET=pdf-ui 580 -> 619.
rustfmt and clippy -D warnings clean.
2026-07-31 22:19:45 +00:00
d8d29c226d feat(pdf): transparency, and four operator-parsing bugs it exposed
Some checks failed
nigig-build.yml / feat(pdf): transparency, and four operator-parsing bugs it exposed (push) Failing after 0s
pdf.yml / feat(pdf): transparency, and four operator-parsing bugs it exposed (push) Failing after 0s
Phase 8 #5 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md
("Advanced transparency"). Design and merge criteria in
REVIEWS/adr/0009-pdf-transparency.md.

The headline defect was not that transparency was missing. `gs` was
MIS-PARSED as CloseStroke:

  /GS0 gs 1 0 0 rg 100 100 200 200 re f
    -> [CloseStroke, RgbFill, Rectangle, FillWinding]

so every page setting a graphics state gained a stroked path the
document never asked for, and lost its alpha, blend mode and soft mask
silently. Downstream everything was dead: StrokeExtGState/FillExtGState
were never constructed, set_fill_opacity was never called and emitted no
command when it was, and PdfPage::ext_gstate was read by no code at all.

New pdf-graphics/src/transparency.rs: full /ExtGState (ca, CA, BM,
SMask, LW, LC, LJ, ML, D, AIS, TK), all sixteen blend modes including
the four non-separable ones, soft masks with /S, /G, /BC and a /TR
evaluated through ADR 0006's PdfFunction, and /Group parsing. Wired end
to end: parser -> PdfOp::SetExtGState -> device -> RenderCommand ->
Makepad renderer.

Compositing is NOT claimed. Backdrop blending needs render-to-texture,
which an engine-neutral crate has no framebuffer for. Constant alpha is
applied because it needs no backdrop; blend modes and soft masks are
reported through TransparencyError::Unsupported rather than dropped,
because a silently ignored /Multiply looks exactly like a correct
/Normal.

The ADR required a test enumerating every multi-character operator, on
the grounds that fixing the third instance of a shadowing bug (after cm
and rg) without preventing the fourth is not a fix. It immediately found
three more live bugs, none of them transparency-related:

- `b` and `b*` dropped their close-path, mapping to FillStroke* instead
  of CloseFillStroke*, so every closed-and-stroked path drew with a gap.
- Text operators within three bytes of the end of a content stream were
  mis-parsed: the b'T' arm guarded `*i + 3 < len` while reading only two
  bytes, so a stream ending in `/F1 12 Tf` parsed as FillWinding.

And the transparency-group fixture found a fourth:

- PdfPage::xobjects was empty for every page of every document.
  extract_xobjects called doc.resolve(), which follows the reference,
  then asked the resolved object for as_ref() - always None - and only
  accepted a bare dict when every XObject is a stream. No `Do` operator
  could be resolved through the page model. Same defect as the one that
  once destroyed annotation object references; it now has its own
  regression test.

T* and BMC are genuinely unimplemented and are deliberately excluded
from the guard's table rather than papered over.

7 corpus fixtures, 12 acceptance tests, 32 unit tests asserting the
§11.3.5 formulas (not our own output), and a parse_ext_gstate fuzz
target.

TEST_TARGET=pdf 451 -> 497, TEST_TARGET=pdf-ui 495 -> 541.
rustfmt and clippy -D warnings clean.
2026-07-31 18:58:31 +00:00
7d21532ebf feat(pdf): real colour spaces, ICC profiles and PDF functions
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Phase 8 #4 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md
("Advanced colour"). Design and merge criteria in
REVIEWS/adr/0006-pdf-advanced-color.md.

The interpreter tracked only the *name* of the active colour space and
then passed sc/scn operands to the device as if they were already RGBA.
Every non-device space therefore rendered a confident wrong colour with
no error:

  /Spot cs 1.0 scn      full tint of a spot ink   -> pure red
  /Idx  cs 3   scn      palette entry 3           -> near-black
  /Lab  cs 50 0 0 scn   mid gray                  -> white (clamped)
  /DevN cs (5 inks)     five colorants            -> inks 5+ discarded
  /ICCBased             profile-defined colour    -> profile discarded

DeviceCMYK also used the additive 1-c-k conversion, which crushes any
colour printed over black.

Three new modules in pdf-graphics:

- function.rs  PDF functions, all four types. Type 4 runs on a bounded
               interpreter: depth 32, 32768 tokens, stack 100, 100000
               steps, and an unknown operator is an error rather than a
               no-op that would leave a plausible wrong colour.
- icc.rs       ICC matrix/TRC and gray kTRC profiles, applied exactly.
               LUT-class profiles are reported as such and the caller
               falls back to /Alternate; they are never pretended to be
               matrix profiles.
- colorspace.rs  All eleven families, converting through XYZ with
               Bradford adaptation and a real sRGB transfer function.

Wiring:

- PdfDevice gains set_stroke_components/set_fill_components, so SC/SCN
  reach the device as components of the active space instead of being
  read positionally as RGBA.
- cs/CS now resets to the space's initial colour (table 74), which is
  why golden/colors.txt gains a line.
- PdfPage::color_spaces carries /Resources /ColorSpace fully
  dereferenced with streams decoded; a half-resolved space would make
  every ICC profile, palette and type 0/4 transform silently fall back.
- A space that cannot be resolved keeps the previous colour and records
  a typed ColorError. No colour is invented, and no error is swallowed.

Tests: 12 corpus fixtures under tests/corpus/color/, 14 acceptance
tests in pdf-document/tests/color.rs asserting numeric RGB (the broken
code produced a colour for every one of these; only the value was
wrong), plus unit tests per function type and per curve type. Two fuzz
targets added: eval_function and parse_colorspace.

TEST_TARGET=pdf 387 -> 447 passing, TEST_TARGET=pdf-ui 431 -> 491.
rustfmt and clippy -D warnings clean.
2026-07-28 20:48:55 +00:00
eacc86077e feat(pdf): complete Phase 5 font engine and text metrics
Phase 5 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md.

Step 5.1, real metrics (new pdf-graphics/src/sfnt.rs):
- Reads head, hhea, OS/2 and hmtx from embedded TrueType/OpenType programs,
  which the review asks for by name. sTypoAscender/Descender are preferred
  over hhea when non-zero, since subset fonts often zero them; sCapHeight
  and sxHeight are only read from OS/2 version 2 and later, because reading
  them from a v1 table returns whatever bytes follow it.
- Every read is bounds-checked. A table pointing outside the file, a
  truncated directory or a zero unitsPerEm yields None rather than a panic
  or a divide by zero. These are untrusted embedded programs.
- resolve_font() now reads FontFile/FontFile2/FontFile3 and falls back to
  the descriptors declared Ascent/Descent only when no program is embedded.
- renderer.rs looks up a per-font ascent instead of leaving ascent_em
  permanently None, which had left the fallback ratio always in effect.

Step 5.2, accumulated advances (new pdf-graphics/src/advance.rs):
- measure_advance() implements PDF 32000-1 9.4.4 properly:
  (w0/1000 * Tfs + Tc + Tw) * Th/100, with Tw restricted to single-byte
  code 32. Applying Tw to a two-byte code whose low byte is 32 is a classic
  composite-font bug and is now covered by a test.
- glyph_advances() gives per-glyph widths so a caret or a partial selection
  rectangle no longer assumes even spacing.
- GlyphAdvanceCache is keyed by font name and size as the review specifies,
  and carries a generation so it rebuilds when page data changes rather
  than every frame. Redefining a font drops its stale measurements.

Step 5.3, composite fonts:
- The old build_cid_widths truncated every CID to u8, so any glyph above
  255 silently took the default width. It is replaced by CidWidths keyed by
  the real CID, with the CMap resolved from a predefined name or an
  embedded stream.
- ResolvedFont::decode_text returns None for a composite font with no
  ToUnicode map instead of guessing Latin-1 and producing plausible
  nonsense.

Tests: new tests/phase5_exit_criterion.rs asserts the exit criterion.
Selection across a proportional and a monospaced run verifies the second
run starts at the first ones true end; per-glyph advances sum to the run
advance and H measures wider than l, which an even-spacing estimate would
not. Copy returns correct Unicode through ToUnicode including a non-ASCII
scalar, and through WinAnsi for simple fonts. Search finds both hits across
two lines with rectangles inside their own runs and the right vertical
order. Cache reuse is asserted through hit and miss counts.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (203 tests)
  TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh   (238 tests)
Both rustfmt and clippy -D warnings clean.
2026-07-27 16:51:18 +00:00
b7d23f26bc feat(pdf): complete Phase 2 render pipeline with golden tests
Phase 2 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md. The exit
criterion is that a real content stream renders through the device path and
is verifiable; previously nothing could fail, so nothing was proven.

Step 2.3, the operations the review calls "the lies", all reached a dead end
in the interpreter. Each now reaches the device:

- Inline images: BI/ID/EI were emitted as two empty marker ops and the
  payload was discarded, so an inline image could never be drawn. They are
  now parsed into a single InlineImage op carrying the dictionary and bytes.
  Abbreviated keys (/W /H /BPC /CS /F) and colour-space and filter
  abbreviations are expanded. The EI scan requires delimiters on both sides
  so binary data containing the bytes "EI" does not truncate the image, and
  an unterminated image yields no image rather than invented pixels.
- Do (XObject) was an empty match arm. The interpreter cannot resolve a
  resource name, so it now reports it through PdfDevice::paint_x_object and
  the device performs the lookup.
- set_dash and set_miter_limit only mutated interpreter-local state and
  emitted no command, so dashes never reached any renderer.

Three further defects surfaced while reviewing the generated goldens, all
of which silently corrupted output rather than failing:

- `cm` was never parsed at all. The single-character `m` arm matched first
  and consumed it as a moveto, so every CTM change in every document was
  lost and content drew at the wrong position. Two-character operators are
  now tested before their one-character prefixes.
- `rg` and `RG` were shadowed by the `r` and `R` arms, so an RGB fill was
  read as a single-component grey: `0.1 0.2 0.3 rg` produced 0.1 0.1 0.1.
- ImageInfo defaulted a missing /Filter to FlateDecode. An absent /Filter
  means the data is stored raw, so every uncompressed image was undecodable.

Testing: adds format_commands(), a deterministic one-line-per-command text
form of a RenderCommand list, and eight golden files covering vector paths,
text, kerning and spacing, dash and stroke parameters, fill rules, inline
images, XObjects and colour operators. Floats are fixed-precision and
negative zero is normalised so no spurious diffs appear. UPDATE_GOLDEN=1
regenerates them for review.

Also fixes .gitignore: blanket *.txt and *.pdf rules were silently excluding
the golden expectations and the AcroForm fixture added in the previous
commit, which would have left both test suites unable to run on a fresh
clone.

Validation: TEST_TARGET=pdf ./tools/test-rust-clean.sh
132 tests pass; rustfmt and clippy -D warnings clean.
2026-07-27 15:55:53 +00:00