Guides · debugging
Your
outline: true produced a byte-identical PDF — Chromium will not write
bookmarks into an untagged file
Published 2026-08-17 · every number below was measured on the day of writing with Chromium 151.0.7922.34, driven by Playwright 1.62.1 and by puppeteer-core 25.8.0 against that same browser binary. The commands are in the article so you can repeat them.
You asked for a document outline. The PDF has no bookmarks pane:
const bytes = await page.pdf({
format: 'A4',
outline: true, // <- silently does nothing
});
// No bookmarks pane. No error. No warning.
Before you go looking for the bug in your document, look at the file. In Playwright, that call returns the same bytes as passing no options at all:
# same page, two calls, Playwright 1.62.1 / Chromium 151.0.7922.34
# a.pdf: page.pdf({})
# b.pdf: page.pdf({ outline: true })
$ md5 -q a.pdf b.pdf
6212364cb50c… 30617 bytes
6212364cb50c… 30617 bytes
# byte-identical. The flag did not reach anything that writes bytes.
Nothing was attempted and nothing failed. The flag was delivered to Chromium, Chromium looked at the document, saw it was not a tagged PDF, and produced an ordinary one. The fix is one more option:
// Playwright — both flags, every time:
await page.pdf({ format: 'A4', outline: true, tagged: true });
// Puppeteer — outline alone is enough; it sets tagged for you:
await page.pdf({ format: 'A4', outline: true });
// and if you are wrapping either one, assert on the artefact, not the call:
// pdfinfo out.pdf | grep -q 'Tagged: *yes'
The rest of this page is why, what the outline is actually built from — which is not your
<h1> tags, quite — and the two traps that come free with
tagged: true.
The sixty-second version
| Symptom | Cause | Fix |
|---|---|---|
Playwright: outline: true does nothing, file is byte-identical |
Playwright's tagged defaults to false, and Chromium writes no
outline into an untagged document |
{ outline: true, tagged: true } |
| Puppeteer: the same code works, so you assume your Playwright call is fine | Puppeteer's tagged defaults to true, and it also forces it on
whenever you ask for an outline |
Nothing to fix in Puppeteer — but do not port the call across as-is |
Puppeteer: you passed tagged: false and got a tagged file anyway |
A deliberate workaround in Puppeteer overwrites your value when
outline is set |
Drop outline if you truly need an untagged file |
| A heading that looks like a heading is missing from the bookmarks | The outline is built from the accessibility tree. A <div> at 28 px bold
is not a heading to anything but your eyes |
Use <h1>–<h6>, or role="heading" aria-level="2" |
| A real heading is missing | display:none takes it out of the accessibility tree, so it is out of the
outline too | Hide it another way, or accept the gap |
| Bookmarks nest oddly | Nesting follows heading levels, not DOM depth — an
<h3> after an <h2> becomes its child wherever it sits |
Fix the level sequence in the document |
| A Chinese document reads out in English | Tagging writes /Lang from the html lang attribute, and with no
attribute Chromium writes its own locale — en-US |
Set <html lang="zh-CN"> before you turn tagging on |
Read it back out of the file first
Two checks, ten seconds, and they tell you which row you are in. The first one is the whole diagnosis in one word:
# 1. is the document tagged at all? this is the question that matters:
pdfinfo out.pdf | grep -i tagged
# Tagged: no <- then there will be no bookmarks, ever
# 2. list the bookmark titles, no extra dependencies:
python3 - out.pdf <<'EOF'
import re, sys, zlib, pathlib
raw = pathlib.Path(sys.argv[1]).read_bytes()
blob = raw
for m in re.finditer(rb'stream\r?\n', raw): # inflate what we can
end = raw.find(b'endstream', m.end())
try: blob += zlib.decompress(raw[m.end():end])
except Exception: pass
print('/Outlines present:', blob.count(b'/Outlines') > 0)
for t in re.finditer(rb'/Title\s*(\(.*?\)|<[0-9A-Fa-f\s]+>)', blob):
raw_t = t.group(1)
if raw_t.startswith(b'<'): # UTF-16BE, e.g. CJK
b = bytes.fromhex(re.sub(rb'[^0-9A-Fa-f]', b'', raw_t[1:-1]).decode())
print(' ', b[2:].decode('utf-16-be') if b[:2] == b'\xfe\xff' else b)
else:
print(' ', raw_t[1:-1].decode('latin-1'))
EOF
Tagged: no and a missing bookmarks pane is this bug. Tagged: yes with no
/Outlines means the tagging worked and the outline flag never arrived. Titles present
but wrong means your document's heading semantics are not what you think — the section after
next.
The measured matrix
One document, eight headings, one option changed at a time, then the whole set again
through puppeteer-core pointed at the Chromium binary Playwright had already
downloaded (chromium.executablePath()), so the engine is constant and the driver is
the variable:
Playwright 1.62.1 — page.pdf(options), same 8-heading document
--------------------------------------------------------------------------
options bytes Tagged /Outlines bookmarks
{} 30617 no absent 0
{ outline: true } 30617 no absent 0 <-- !!
{ tagged: true } 33846 yes absent 0
{ outline: true, tagged: true } 34886 yes present 6
puppeteer-core 25.8.0 — same Chromium binary, same document
--------------------------------------------------------------------------
{} 34059 yes absent 0
{ outline: true } 35099 yes present 6
{ outline: true,
tagged: false } 35099 yes present 6 <-- !!
{ tagged: false } 30830 no absent 0
Rows 2 and 3 of the Playwright block are the whole bug. Row 3 of the
Puppeteer block is byte-for-byte identical to row 2: the explicit
tagged: false was thrown away.
Two things worth stopping on. The Playwright outline: true row is not "the outline
came out empty" — the file is the same size and the same checksum as the
baseline. And the Puppeteer tagged: false row is the same checksum as the row above
it, which means an explicit false was discarded on the way through.
Why the two drivers disagree
Neither library is doing anything mysterious; they made different decisions about the same Chromium quirk. Puppeteer patches around it, with a comment naming the bug:
// puppeteer-core, packages/puppeteer-core/src/common/util.ts
// (line 361 in main at the time of writing; identical in the 25.8.0 build)
const defaults = {
…
outline: false,
tagged: true, // <- Puppeteer's default. Playwright's is false.
};
// Quirk https://bugs.chromium.org/p/chromium/issues/detail?id=840455#c44
if (options.outline) {
options.tagged = true;
}
Playwright passes your values through and lets Chromium decide:
// playwright-core, the CDP call behind page.pdf() — no such workaround:
const generateDocumentOutline = outline;
const generateTaggedPDF = tagged; // default false, straight through
await this._client.send('Page.printToPDF', {
…, generateTaggedPDF, generateDocumentOutline
});
So the behaviour you are fighting belongs to Chromium's print pipeline — an outline entry has to point at something in the document's structure tree, and an untagged PDF has no structure tree to point at. Puppeteer decided that asking for an outline implies asking for tagging. Playwright decided your two arguments mean what they say. Both are defensible; the cost lands on anyone reading a Puppeteer answer to a Playwright question, which is most of what a search for this returns.
outline option is broken. It is not broken. It is one of two options where the
other library has one and a half.What the outline is built from — not your tags, quite
The dependency on tagging is a clue about the mechanism, and it holds up. Here is the document behind the matrix above; note the three deliberate oddities:
<h1>ALPHATOP</h1>
<h2>BRAVOONE</h2>
<h3>CHARLIESUB</h3>
<div style="font-size:28px;font-weight:700">ECHOFAKE</div>
<div role="heading" aria-level="2">GOLFARIA</div>
<h2 style="display:none">FOXTROTHIDDEN</h2>
<h2>中文标题DELTA</h2>
<h2>DELTATWO</h2>
And here is the outline Chromium produced from it, straight out of the file:
% the /Outlines tree out of that document, verbatim
46 <</Type /Outlines /First 47 0 R /Last 47 0 R /Count 6>>
47 <</Title (ALPHATOP) /Dest [2 0 R /XYZ 6.52 773.83 0] /Parent 46 0 R
/SE 16 0 R /First 48 0 R /Last 52 0 R /Count 5>>
48 <</Title (BRAVOONE) /Dest [2 0 R /XYZ 7.23 712.18 0] /Parent 47 0 R
/SE 20 0 R /Next 50 0 R /First 49 0 R /Last 49 0 R /Count 1>>
49 <</Title (CHARLIESUB) /Dest [2 0 R /XYZ 6.54 656.35 0] /Parent 48 0 R
/SE 24 0 R>>
50 <</Title (GOLFARIA) /Dest [2 0 R /XYZ 6.50 544.74 0] /Parent 47 0 R
/SE 32 0 R /Prev 48 0 R /Next 51 0 R>>
51 <</Title <FEFF4E2D65876807989800440045004C00540041>
/Dest [2 0 R /XYZ 7.43 495.36 0] /Parent 47 0 R /SE 36 0 R>>
52 <</Title (DELTATWO) /Dest [2 0 R /XYZ 7.19 436.93 0] /Parent 47 0 R
/SE 40 0 R /Prev 51 0 R>>
% ECHOFAKE and FOXTROTHIDDEN are not in there at all.
% every entry carries /SE — a reference into the structure tree.
Six entries out of eight candidates. The 28 px bold <div>
(ECHOFAKE) is absent — visual weight is not structure. The
display:none heading (FOXTROTHIDDEN) is absent — it is out of the
accessibility tree, so it is out of here. And <div role="heading" aria-level="2">
(GOLFARIA) is present, at level 2, sitting between two real
<h2> elements. That last row is the one that settles it: what Chromium walks is the
accessibility tree, not a list of h1…h6 elements. If you have a component
library that renders headings as styled <div>s with ARIA — a lot of design systems
do — your bookmarks will be correct, and if it renders them as styled <div>s without
ARIA, no amount of option-tweaking will produce them.
Two details for anyone generating these programmatically. Nesting comes from the heading
level: object 49 (the <h3>) is a child of object 48 (the <h2>),
which is a child of the <h1>, and /Count on each node is the number of
descendants a viewer will show when it is open. Each entry's /Dest is
[page /XYZ x y 0] — an explicit coordinate on a page object, so clicking a bookmark
scrolls to the heading's own position rather than to the top of its page.
CJK titles survive; the language declaration is the trap
Object 51 in that dump is the Chinese heading, written as
/Title <FEFF4E2D6587…> — a UTF-16BE string with a byte-order mark, which is the
correct PDF encoding for anything outside Latin-1. It decodes back to
中文标题DELTA exactly. Bookmark text in Chinese, Japanese, Korean,
Greek, Cyrillic or emoji needs nothing special from you, and a bookmarks pane full of
? characters means your PDF viewer, not your pipeline.
The trap sits next door. Turning tagging on also writes a language into the document catalog, and it is taken from the page:
page.pdf({ outline: true, tagged: true }) — what lands in the Catalog:
<html> /Lang (en-US) <- your browser's locale
<html lang="zh-CN"> /Lang (zh-CN)
<html lang="en"> /Lang (en)
A Chinese document with no lang attribute ships declaring itself en-US.
Nothing warns you; the text renders correctly and reads out wrong.
With no lang attribute the file does not come out language-neutral — it comes out
asserting en-US, because that is the locale of the Chromium doing the printing.
For a Latin-script document nobody notices. For a Chinese or Japanese one you have shipped a
document that tells assistive technology to pronounce it as American English, which is worse
than saying nothing. If you are turning on tagging for accessibility reasons, set
lang in the same change.
What tagged: true actually gives you, and what it does not
Since the fix for the bookmarks is to turn tagging on, it is worth knowing what else that option changed in your output. Measured on a second page carrying the usual structures:
one page: an <img alt>, a <table> with <caption>/<th>/<td>, a <ul>, a link
--------------------------------------------------------------------------
tagged: false tagged: true
file size 24472 bytes 27826 bytes
/StructTreeRoot absent present
/Figure 0 2
/Alt with the alt text 0 1 (text is in the file)
/Table /TH /TD /Caption 0 0 0 0 13 2 2 1
/L /LI (list structure) 0 0 1 1
/Link structure elements 0 2
and what is still missing in both:
XMP metadata packet absent absent
PDF/UA identifier absent absent
That is real structure, not a flag: the image's alternative text is genuinely in the file, the table knows which cells are headers, the list is a list, and links are structure elements rather than bare rectangles. Roughly 3 KB on a 24 KB document here, and the outline itself added about 1 KB on top.
What it is not: a claim of conformance. There is no XMP metadata packet in the output and
no PDF/UA identifier, so a checker will not see a declared conformance level. Untagged images
produce /Figure elements with no /Alt, reading order follows the print
layout, and nothing validates your heading sequence. tagged: true gets you a
structured PDF — a much better starting point than a page of unlabelled text — and the
remaining distance to an audited accessible document is yours to walk. Treat the flag as
necessary, not sufficient, and if a contract says PDF/UA, run a validator against the artefact
rather than trusting the option name.
If you would rather not own this
Owning the browser is a fixed cost — an afternoon, then a few lines in a helper, then it stops bothering you. It also means you can turn tagging on for every document you produce, which is worth more than the bookmarks that sent you here.
If handing the render to a service is more attractive, snapdok.io is one
option, and on this particular topic we should be straight with you:
our API does not expose outline or tagged today. There is no
parameter for either, so if bookmarks or a tagged tree are the reason you are here, driving
Chromium yourself — Playwright with both flags, or Puppeteer with one — is the shorter path,
and several other vendors expose PDF/UA options that we do not. See
the flag map for what the various tools expose.
What we are good at is the boring part around this: getting a page to render faithfully,
fonts and all, without a browser in your own infrastructure.
The short version
In Playwright, outline: true without tagged: true is a silent no-op that
returns byte-identical output, because Chromium hangs outline entries off the structure tree
and an untagged document has none. Puppeteer forces tagged on for you — even over an
explicit false — which is why the same snippet behaves differently in the two
libraries. The entries themselves come from the accessibility tree, so
role="heading" counts and a bold <div> does not, display:none
removes a heading from both, and nesting follows heading levels. CJK titles are written as
UTF-16BE and need nothing from you, but tagging also stamps /Lang from
html lang — and with no attribute you ship en-US whether that is true or
not. One command tells you where you stand: pdfinfo out.pdf | grep -i tagged.
Related, from the same measurements:
why your PDF header is not showing —
five causes, including the 1 px default font inside header templates — and
why landscape: true loses
to a line of CSS, where a page's own @page rule outranks the option you passed,
and why your images are missing from the
PDF — including the document.fonts.ready call that resolves for a font that never
arrived.
Sources, all read 2026-08-17:
Playwright's
page.pdf() reference — outline is "Whether or not to embed the
document outline into the PDF. Defaults to false" and tagged is "Whether
or not to generate tagged (accessible) PDF. Defaults to false"; the dependency
between them is not stated in either entry, and the defaults are quoted from the installed
playwright-core 1.62.1 type definitions ·
Puppeteer's
PDFOptions reference — tagged defaults to true,
outline to false, both marked experimental ·
Puppeteer's
parsePDFOptions — the defaults table and the three-line workaround quoted
above, read from the current main branch and matched against the installed
25.8.0 build ·
Chromium
issue 840455, which is the bug that comment cites ·
MDN
on the ARIA heading role — the aria-level semantics our fixture
relies on. Every measurement is ours, taken on 2026-08-17 with Chromium 151.0.7922.34 via
Playwright 1.62.1 and via puppeteer-core 25.8.0 launched against the same binary, and
repeatable with the commands above.