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.
214 lines
9.9 KiB
Markdown
214 lines
9.9 KiB
Markdown
# ADR 0009: PDF transparency — ExtGState, blend modes and soft masks
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-07-31
|
|
- **Review item:** Phase 8, `DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md`
|
|
("Advanced transparency (blend modes, soft masks, groups)")
|
|
- **Supersedes:** nothing
|
|
- **Related:** ADR 0006 (advanced colour) — its function evaluator is the
|
|
prerequisite for soft-mask transfer functions, and its `ColorSpace` is
|
|
what a luminosity mask's backdrop is measured in
|
|
|
|
## Context
|
|
|
|
The `gs` operator is not merely unimplemented. **It is mis-parsed into a
|
|
different operator**, and the one it produces draws.
|
|
|
|
The content-stream dispatcher matches `b's'` for the close-and-stroke
|
|
operator before anything matches `gs`, and its `b'g'` arm requires a
|
|
delimiter, so `gs` never reaches it. Probing `/GS0 gs 1 0 0 rg 100 100 200
|
|
200 re f` through the current parser:
|
|
|
|
```
|
|
ops parsed: 4
|
|
CloseStroke <- /GS0 gs became this
|
|
RgbFill(1.0, 0.0, 0.0)
|
|
Rectangle(100.0, 100.0, 200.0, 200.0)
|
|
FillWinding
|
|
```
|
|
|
|
So every page that sets a graphics state — which is most pages with any
|
|
transparency, and a great many without — gets a **spurious stroked path**
|
|
injected at that point. This is the third instance of the same defect class
|
|
already fixed twice in this codebase: `cm` swallowed by `m`, and `rg`/`RG`
|
|
shadowed by `r`/`R`. Single-character operators were matched before their
|
|
two-character extensions.
|
|
|
|
Downstream of that, the rest of the transparency path is dead code:
|
|
|
|
- `PdfOp::StrokeExtGState` and `PdfOp::FillExtGState` exist in the enum and
|
|
**are never constructed and never interpreted**.
|
|
- `PdfDevice::set_stroke_opacity` / `set_fill_opacity` exist and are **never
|
|
called by the interpreter**. `RecordingDevice`'s implementations mutate
|
|
`current_state` but emit no `RenderCommand`, so even a direct call would
|
|
be invisible to any renderer.
|
|
- `PdfPage::ext_gstate` is parsed out of `/Resources /ExtGState` into an
|
|
`ExtGStateResource { ca, ca_lower }` that **no code anywhere reads**. It
|
|
also captures only the two alpha constants, discarding `/BM`, `/SMask`,
|
|
`/LW`, `/LC`, `/LJ`, `/D`, `/Font` and the rest.
|
|
|
|
The net effect: `ca`/`CA` are silently ignored, so a watermark at 10% alpha
|
|
renders fully opaque; blend modes are silently ignored, so a `/Multiply`
|
|
highlight paints over the text it should tint; and soft masks are silently
|
|
ignored, so a gradient-masked image paints as a hard rectangle. Each is a
|
|
confident wrong result with no error — the defect class rule 6 exists to
|
|
prevent.
|
|
|
|
## Decision
|
|
|
|
Implement the transparency model of PDF 32000-1 §11 that can be computed
|
|
correctly on the CPU, in `pdf-graphics`, and be explicit about the boundary
|
|
where it stops.
|
|
|
|
### Fix the parser bug first, and generalise the guard
|
|
|
|
`gs` gets its own arm. More importantly, the recurring shadowing bug gets a
|
|
test that enumerates **every** multi-character operator in the spec and
|
|
asserts each parses to itself rather than to a prefix of itself. Fixing the
|
|
third instance of a bug without preventing the fourth is not a fix.
|
|
|
|
### `ExtGState`, in full
|
|
|
|
`/ExtGState` is parsed into a real structure carrying `ca`, `CA`, `BM`,
|
|
`SMask`, `LW`, `LC`, `LJ`, `ML`, `D`, `AIS` and `TK`, and `gs` applies all
|
|
of them to the graphics state. The entries that duplicate existing
|
|
operators (`LW`, `LC`, `LJ`, `ML`, `D`) are applied through the same device
|
|
calls those operators use, so there is one code path per effect.
|
|
|
|
### Blend modes
|
|
|
|
All sixteen separable and non-separable modes of §11.3.5: Normal,
|
|
Multiply, Screen, Overlay, Darken, Lighten, ColorDodge, ColorBurn,
|
|
HardLight, SoftLight, Difference, Exclusion, Hue, Saturation, Color and
|
|
Luminosity. These are pure functions on backdrop and source colour, fully
|
|
specified and exactly testable, so they are implemented and unit-tested
|
|
against the formulas rather than approximated.
|
|
|
|
An unrecognised blend-mode name falls back to Normal — which is what the
|
|
specification itself requires (§11.3.5.2: "if the name is not recognised, it
|
|
shall be treated as Normal") — and is recorded so the caller knows.
|
|
|
|
### Soft masks
|
|
|
|
`/SMask` in an ExtGState is parsed into its `/S` subtype (`/Alpha` or
|
|
`/Luminosity`), its `/G` transparency group, its `/BC` backdrop colour and
|
|
its `/TR` transfer function. The transfer function is evaluated with ADR
|
|
0006's `PdfFunction`, which is why that ADR was the prerequisite for this
|
|
one. `/None` clears the mask.
|
|
|
|
### Transparency groups
|
|
|
|
`/Group` dictionaries on Form XObjects and pages are parsed: `/S
|
|
/Transparency`, `/CS` (through ADR 0006's colour spaces), `/I` isolated and
|
|
`/K` knockout.
|
|
|
|
### What is emitted, and what is not
|
|
|
|
The interpreter emits typed render commands — `SetBlendMode`,
|
|
`SetSoftMask`, `ClearSoftMask`, `BeginTransparencyGroup`,
|
|
`EndTransparencyGroup`, `SetFillAlpha`, `SetStrokeAlpha` — carrying fully
|
|
resolved parameters.
|
|
|
|
**Actual GPU compositing is not implemented here and is not claimed.**
|
|
`pdf-graphics` is engine-neutral and has no framebuffer; performing real
|
|
backdrop compositing requires render-to-texture in the Makepad layer. What
|
|
this ADR delivers is that the information reaches the device *correctly and
|
|
completely* instead of being dropped or mis-parsed, plus a CPU
|
|
implementation of every blend function that a compositor can call. The
|
|
Makepad renderer applies constant alpha immediately, because it can, and
|
|
reports the rest as unsupported rather than pretending.
|
|
|
|
That boundary is stated in the code, not just here: `RenderCommand`s a
|
|
device cannot honour are surfaced through a typed
|
|
`TransparencyError::Unsupported` list rather than silently ignored.
|
|
|
|
## Non-negotiable rules
|
|
|
|
1. **No operator may be parsed as a different operator.** The multi-char
|
|
operator test enumerates the spec's table and fails on any shadowing.
|
|
2. **No dead integration points.** Anything added here is constructed,
|
|
interpreted, emitted and asserted end to end. This ADR exists partly
|
|
because the previous transparency scaffolding was never wired.
|
|
3. **Unsupported compositing is reported, never silently skipped.** A blend
|
|
mode the renderer cannot execute is named in a typed error.
|
|
4. **Blend functions match the specification's formulas**, tested against
|
|
values computed from §11.3.5, not against our own output.
|
|
5. **A soft mask that cannot be resolved does not become "no mask"**
|
|
silently; it is reported.
|
|
|
|
## Merge criteria
|
|
|
|
- [x] `gs` parses as `gs` and no longer produces a spurious `CloseStroke`.
|
|
- [x] A regression test enumerates every multi-character operator and
|
|
asserts none is shadowed by a shorter one.
|
|
- [x] `/ExtGState` is parsed in full and `gs` applies it to the state.
|
|
- [x] `ca` and `CA` reach the device and are observable in the command list.
|
|
- [x] All sixteen blend modes are implemented and unit-tested against the
|
|
specification's formulas, including the four non-separable ones.
|
|
- [x] An unknown blend mode falls back to Normal and is reported.
|
|
- [x] `/SMask` is parsed with subtype, group, backdrop and transfer
|
|
function; `/None` clears it.
|
|
- [x] A soft-mask transfer function is evaluated through ADR 0006's
|
|
`PdfFunction`.
|
|
- [x] `/Group` dictionaries are parsed with colour space, isolated and
|
|
knockout flags.
|
|
- [x] Corpus fixtures for constant alpha, each blend-mode family, a
|
|
luminosity soft mask and a transparency group, generated by the
|
|
checked-in script.
|
|
- [x] Operations the renderer cannot composite are reported as typed
|
|
unsupported entries, not dropped.
|
|
- [x] `TEST_TARGET=pdf ./tools/test-rust-clean.sh` passes, rustfmt and
|
|
clippy `-D warnings` clean.
|
|
|
|
All criteria met. `TEST_TARGET=pdf` went from 451 to 497 and
|
|
`TEST_TARGET=pdf-ui` from 495 to 541.
|
|
|
|
### What the operator-shadowing guard found
|
|
|
|
Writing the enumerating test was the highest-value part of this work. It
|
|
immediately failed on operators unrelated to transparency, each a live bug:
|
|
|
|
- **`b` and `b*` dropped their close-path.** Both mapped to
|
|
`FillStroke*` instead of `CloseFillStroke*`, so every closed-and-stroked
|
|
path was drawn with a visible gap.
|
|
- **Text operators within three bytes of the end of a content stream were
|
|
mis-parsed.** The `b'T'` arm guarded `*i + 3 < data.len()` when it reads
|
|
only two bytes, so a stream ending in `/F1 12 Tf` parsed as
|
|
`FillWinding` — a font selection silently became a fill. The inner arms
|
|
also rejected end-of-stream as a delimiter.
|
|
|
|
A third bug was found by the transparency-group fixture rather than the
|
|
guard:
|
|
|
|
- **`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()`, which is always `None`.
|
|
It also only accepted a bare dict, while every XObject is a stream. So
|
|
no `Do` operator could ever be resolved through the page model. This is
|
|
the same defect that once destroyed annotation object references, and it
|
|
now has its own regression test.
|
|
|
|
`T*` and `BMC` are genuinely unimplemented and are deliberately not in the
|
|
guard's table; they are recorded here rather than papered over.
|
|
|
|
## Consequences
|
|
|
|
**Positive.** The spurious-stroke bug disappears from every page using
|
|
`gs`, which is a correctness fix well beyond transparency. Constant alpha
|
|
works. The blend functions are exact and reusable by any future
|
|
compositor. The soft-mask and group data is available for the GPU work
|
|
rather than needing a second parsing pass.
|
|
|
|
**Negative.** `RenderCommand` grows, and devices must handle the new
|
|
variants. The alternative — leaving them out — is what produced the dead
|
|
hooks this ADR is cleaning up.
|
|
|
|
**Risk.** The temptation to claim "transparency is done" when compositing
|
|
is not. Mitigated by the explicit boundary above, by the typed unsupported
|
|
reporting, and by not ticking a criterion this ADR did not set.
|
|
|
|
**Out of scope, deliberately:** GPU backdrop compositing and
|
|
render-to-texture; knockout and isolation *semantics* during compositing
|
|
(the flags are parsed and reported, not enforced); spot-colour overprint
|
|
and `/OPM`; `/TK` text knockout behaviour; and blend modes applied to
|
|
images rather than to fills and strokes.
|