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

SymptomCauseFix
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 tooHide 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.

This also explains a smaller mystery: the same snippet copied out of a Puppeteer blog post works in Puppeteer and no-ops in Playwright, so the natural conclusion is that Playwright's 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 h1h6 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() referenceoutline 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 referencetagged 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.