Eight patches · BSD 3-Clause

PDF layers,
for PDFium

PDF has had layers since 2001 — optional content groups, in the specification's words. CAD exports, multilingual artwork, maps with switchable overlays and watermarked drafts all use them. Almost no viewer outside Acrobat lets you touch them.

PDFium is the reason why, and it is also the fix. It renders PDFs in Chrome, Edge and most of the embedded viewers on the web, and it has understood optional content internally for years — it just never exposed it. There is no fpdf_ocg.h. pdfium-ocg adds that API and demonstrates it end to end: an eight-patch series against upstream PDFium, a port onto EmbedPDF's WebAssembly build, and a working Layers panel you can click through in a browser.

Buildings vanishes and returns, Grid switches off, and ticking Labels (French) switches English off by itself — the document declares the two a /RBGroups radio pair, and the panel honours it. The headings are the document's own /Order tree, and nothing here writes to the file: visibility is view state.

Try it

No install and no clone: the WebAssembly build runs in your browser.

The live demo opens a ten-layer site plan in the EmbedPDF viewer, compiled against the patched PDFium. Open the layers tab in the sidebar, then:

  • Uncheck a layer. The drawing repaints without it. Nothing is written to the file, so this works on a read-only PDF.
  • Tick Labels (French). Labels (English) switches itself off, because the document declares the two a radio pair.
  • Draw something, then hide the layer you drew it on. Pick a layer, switch to Shapes and draw a rectangle: it lands on that layer and hides with it. This is the part that matters most — almost nobody hand-authors optional content, so an annotation is usually the only thing that will ever be on a layer.
  • Select an annotation and press the layers button on its toolbar for a dropdown of every layer — pick one to move it there, or No layer to take it off layers entirely.
  • Delete a layer. It asks what should happen to what is drawn on it. There is no safe default: content still naming a removed layer renders visible, so silently keeping it would reveal the very thing you meant to delete.
  • Drag a row. Reordering rewrites the document's /Order; drag a heading and its children move with it.
An annotation's toolbar with its layers button open, listing every layer in the document
Annotations belong to layers too: the layers button on an annotation's toolbar moves it to any layer.

What the patches do

Eight patches, each a self-contained change with its own tests, written to be reviewed in order.

#PatchWhat it adds
1Honour /OC on annotationsA spec-compliance fix with no API change
2Enumerate and queryFPDFDoc_GetOCGCount, FPDFDoc_GetOCG, FPDFOCG_GetName, FPDFOCG_GetVisibility
3Runtime toggleFPDFOCG_SetVisibility, FPDFOCG_ResetVisibility — the headline
4The /Order treeFPDFDoc_GetOCGOrder, FPDFOCGNode_* — what a Layers panel needs to draw itself
5/Locked and /RBGroupsFPDFOCG_IsLocked, FPDFDoc_GetOCGRadioGroup*
6AuthoringFPDFDoc_AddOCG, FPDFAnnot_GetOCG / FPDFAnnot_SetOCG
7EditingFPDFDoc_RemoveOCG, FPDFOCGNode_MoveChild, FPDFPageObj_GetOCG / FPDFPageObj_SetOCG
8Tree shapingFPDFOCGNode_AddGroup, FPDFOCGNode_MoveChildTo — headings and re-parenting

Patch 1 is the one to read first if you only read one. It is not part of the API work at all: PDFium filters annotations on the Hidden, Print and NoView flags and nothing else, so an annotation on a hidden layer draws anyway, although ISO 32000-1 §12.5.2 says /OC shall be evaluated before drawing. That is a plain bug that predates this work, and it is fixed on its own with its own test.

The state of it

At the tip of the series, 934 embedder tests and 1097 unit tests pass, and PDFium's api_check.py and git cl format are clean.

Nothing has been submitted upstream yet. A Google CLA comes first, and there is one open design question worth settling on the mailing list before review: whether FPDFOCG_SetVisibility should refuse to change a /Locked group, or report the lock and let the embedder decide. This series takes the second position; the repository also holds a working implementation of the first, so the discussion can be about code.

Prior art: PDFium bug 1136, “Public API for optional contents groups”, filed in 2018 and still open. A maintainer called it feasible; the reporter narrowed the request to getting each group's count, name and state, and setting the state. That is patches 2 and 3.

Building it

scripts/bootstrap.sh clones the upstream trees at their pinned base commits and replays the repository's patches onto each. It fetches and patches; it deliberately does not build.

./scripts/bootstrap.sh            # everything
./scripts/bootstrap.sh pdfium     # just the upstream series
./scripts/bootstrap.sh v2         # just the v2 viewer (the complete one)

The patches cover two EmbedPDF lines, because they are different products with the same name. v2, the @embedpdf/snippet package people ship today, is where the layers work is finished, and the demo is a build of it. v3 is the ground-up rewrite, still catching up with v2; the layers work there is equivalent but sits on a different architecture.

The README has the full build instructions for PDFium, the WebAssembly runtime and the viewer, and PLAN.md has the design notes, including the alternatives that were rejected and why.