Guides · debugging
Your
landscape: true is losing to a line of CSS — and
preferCSSPageSize is not the fix
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 the same browser binary. The command lines are in the article so you can repeat them.
You asked for landscape. You got portrait:
const bytes = await page.pdf({
format: 'A4',
landscape: true, // <- ignored
});
// 595.9 x 842.9 pt. Portrait. No error, no warning.
The option is not broken and you have not misspelled it. Somewhere in the CSS that page
loads there is an @page rule, and in Chromium's print pipeline that rule
outranks the option you passed — in both directions. It can turn landscape on when you never
asked, and it can turn it off when you asked explicitly.
The single most useful sentence on this page: preferCSSPageSize decides
whether CSS controls the paper size. It has nothing to do with orientation, which CSS
controls either way. Almost every wrong answer to this problem comes from assuming
that flag is the on/off switch for "does my CSS matter".
The sixty-second version
| Symptom | Cause | Fix |
|---|---|---|
landscape: true produces portrait |
An @page { size: portrait } rule in the page's CSS, or a
size whose dimensions are taller than wide |
Override it in the page, or move all geometry into CSS and set
preferCSSPageSize: true |
| You get landscape without asking for it | @page { size: landscape } from a print stylesheet you did not write |
Find the rule (snippet below), then neutralise it |
| Right paper size, but the content sits in a small island in the middle | A CSS @page size smaller than the paper. It still drives layout;
it is centred on the sheet | Make them agree, either way round |
| Right paper size, but everything is ~30% too small | A CSS @page size larger than the paper — Bootstrap 4 shipped
a3 — so the whole page is scaled down to fit |
Same: make them agree |
Your margin option does nothing |
An @page { margin: … } rule in the CSS wins outright, including
margin: 0 | Remove it, or set the margins in CSS instead |
landscape: true turned your wide page narrow |
With width/height, landscape swaps them rather
than forcing an orientation | Drop the option, or swap the two values back |
How to read this back out of your own PDF
Do this before you change any code. It takes ten seconds and it tells you which of the six rows above you are in:
# what size did I actually get?
pdfinfo out.pdf | grep 'Page size'
# Page size: 842.88 x 595.92 pts
# per page, because one document can hold several sizes:
pdfinfo -f 1 -l 5 out.pdf | grep 'size'
# where did the content land on the paper?
pdftotext -bbox out.pdf - | grep PROBEONE
# <word xMin="88.50" yMin="128.40" xMax="260.20" …>PROBEONE</word>
# ^ 88.5pt from the left edge of a page with no margins set
Everything below is geometry read out of finished PDFs the same way — one two-page
document, one thing changed at a time, pdfinfo for the sheet and
pdftotext -bbox for where the ink landed. Then the whole set again through
puppeteer-core pointed at the Chromium that Playwright had already downloaded
(chromium.executablePath()), so the engine is constant and the driver is the
variable.
Baseline: what the options do on their own
With no @page rule anywhere, the options behave exactly as documented — with
one exception, in the last two rows:
page.pdf() options, no @page rule anywhere in the document
-------------------------------------------------------------------------
{} 612.00 x 792.00 portrait
{ landscape: true } 792.00 x 612.00 landscape
{ format: 'A4' } 595.92 x 842.88 portrait
{ format: 'A4', landscape: true } 842.88 x 595.92 landscape
{ width: '200mm', height: '100mm' } 567.10 x 283.90 landscape
{ width: '200mm', height: '100mm',
landscape: true } 283.90 x 567.10 PORTRAIT
Read those last two rows again. width: '200mm', height: '100mm' is already a
landscape sheet. Adding landscape: true made it portrait. The option
does not mean "make this landscape"; it means "swap width and height". With
format that is the same thing, because the named formats are all defined portrait.
With explicit width/height it is not, and passing
landscape: true for luck will undo the orientation you just asked for. Playwright's
reference calls the option "Paper orientation. Defaults to false"; the swap is
not stated, and it is the whole behaviour.
Cause 1 — a CSS @page rule outranks the option, both ways
Four renders, same document, format: 'A4' in all of them,
preferCSSPageSize never set:
CSS in the page page.pdf() option resulting sheet
-------------------------------------------------------------------------
(nothing) landscape: true 842.9 x 595.9 land
@page { size: landscape } (none passed) 842.9 x 595.9 land
@page { size: portrait } landscape: true 595.9 x 842.9 PORT
@page { size: landscape } landscape: false 842.9 x 595.9 LAND
format: 'A4' in all four rows. preferCSSPageSize never set.
Row 2 is the surprise for people who have read about preferCSSPageSize: with the
flag off, an @page { size: landscape } rule still flipped the sheet. Row 3 is
the surprise for everyone else: an @page { size: portrait } rule beat an explicit
landscape: true. Row 4 rules out "maybe the option was just unset" —
landscape: false loses too.
Page.printToPDF, and Chromium resolves the page box
the way a browser does: CSS is part of the document, and the document has an opinion about
its own pages. What is missing is a warning — nothing tells you the option you passed was
overruled.Where the rule comes from when you did not write one
The usual answer is a framework's print stylesheet. A concrete, checkable example:
Bootstrap 4 ships one. In scss/_print.scss it emits
@page { size: $print-page-size; }, and in scss/_variables.scss the
default is $print-page-size: a3 !default; — so a stock Bootstrap 4 build asks
every printed page to be A3. Bootstrap 5 dropped it: scss/bootstrap.scss in
5.3.3 imports no print partial at all. Both files are linked at the bottom of this page; the
quoted lines are from the tagged source.
Other places it hides, in rough order of how often we have seen them: a
@media print block in the application's own CSS (which works —
@page nested in @media print applied in our run, while the same rule
in @media screen did not, because Chromium prints with the print
media type); a vendored "print.css" from a previous decade; and a rule added by whoever last
fixed the PDF for a different paper size.
Rather than guess, enumerate. CSSPageRule is a real object in the CSSOM, so
the rules are discoverable from the page itself:
// paste into the page you are about to print, or run it with page.evaluate()
const found = [];
const walk = (rules, where) => {
for (const r of rules) {
if (r instanceof CSSPageRule) {
found.push({ where, selector: r.selectorText || '(all pages)',
size: r.style.getPropertyValue('size'),
margin: r.style.getPropertyValue('margin'), css: r.cssText });
} else if (r.cssRules) {
walk(r.cssRules, where + ' > ' + (r.conditionText || r.constructor.name));
}
}
};
for (const [i, sheet] of [...document.styleSheets].entries()) {
try { walk(sheet.cssRules, sheet.href || `<style> #${i + 1}`); }
catch (e) { found.push({ where: sheet.href, error: 'unreadable: ' + e.name }); }
}
console.table(found);
Against a document with three stylesheets, that returned:
[
{ where: '<style> #1', selector: '(all pages)', size: 'a3 landscape' },
{ where: '<style> #2 > print', selector: '(all pages)', size: 'legal' },
{ where: '<style> #3', selector: ':first', size: 'a5' }
]
Note the second row: the walk descends into @media blocks, which is where
these usually live. Note also the catch — a stylesheet loaded from another origin
without CORS headers throws on sheet.cssRules, so a cross-origin CSS file is a blind
spot this snippet reports rather than skips. And when two rules apply, the ordinary cascade
decides: we measured @page{size:landscape}@page{size:portrait} as portrait and the
reverse order as landscape. Last one wins, so a rule you add later can override a framework's.
Cause 2 — what preferCSSPageSize actually switches
Playwright documents it as "Give any CSS @page size declared in the page
priority over what is declared in width and height or
format options. Defaults to false." That is accurate, and it is
narrower than it reads. Same document, format: 'A4', flag off and on:
preferCSSPageSize resulting sheet
--------------------------------------------------------------------------
@page { size: A5 } off 595.9 x 842.9 A4
@page { size: A5 } on 420.0 x 595.0 A5
@page { size: A5 landscape } off 842.9 x 595.9 A4 land
@page { size: A5 landscape } on 595.0 x 420.0 A5 land
@page { size: landscape } on 842.9 x 595.9 A4 land
@page { size: 200mm 100mm } off 842.9 x 595.9 A4 land
@page { size: 100mm 200mm } off 595.9 x 842.9 A4
format: 'A4' throughout. Read rows 1 and 3 together.
Rows 1 and 3 are the whole lesson. Both have a CSS size the flag is supposedly suppressing. In row 1 the size is suppressed — A5 asked for, A4 delivered. In row 3 the size is suppressed too, and the orientation keyword travelled anyway: A4 paper, turned sideways. The flag governs the size. Orientation is not the size.
Row 6 sharpens it. There is no keyword there at all — just
size: 200mm 100mm, a wider-than-tall pair — and the sheet flipped to A4 landscape.
Row 7 is the control: swap the two values and it stays portrait. So Chromium takes orientation
from the shape of the CSS page box as well as from the keyword. A bare named size does not
flip anything, because every named size in the CSS spec is defined portrait —
ledger, which is landscape-shaped in Puppeteer's option table, is
11in × 17in portrait as a CSS keyword, and we measured it not flipping.
Cause 3 — the CSS page box always drives layout, even when it does not drive the paper
This is the one that produces the two complaints that sound unrelated — "my PDF has enormous margins I did not ask for" and "my PDF is the right size but the text is tiny". They are the same cause with the numbers pointing in opposite directions.
With preferCSSPageSize off, the CSS @page size is not ignored. It still
lays the document out, and pagination follows it — a long paragraph that fills two A4 pages
filled four with @page{size:A5} and format:'A4', the same four as a genuine
A5 render. That layout is then fitted onto the sheet the options asked for. We predicted the
fit as scale by min(1, sheet ÷ box), then centre, and measured it:
one 40px word, body margin 0, no @page margin. min(1, fit) predicted vs measured:
CSS @page box sheet (format) scale glyph width content x predicted x
-------------------------------------------------------------------------------
A3 841.9x1191.1 A5 420.0x595.9 0.499 85.7 pt 0.0 0.0
B5 498.9x 708.7 A4 595.9x842.9 1.000 171.7 pt 48.7 48.5
A5 420.0x 595.0 A4 595.9x842.9 1.000 171.7 pt 88.5 87.9
A4 595.0x 841.9 A4 595.9x842.9 1.000 171.7 pt 0.0 0.4
85.7 / 171.7 = 0.499 — the same word, scaled by exactly the predicted factor.
The content offsets land within 0.6 pt of the prediction in all four rows, and the glyph width settles it: the same word at the same CSS size measures 171.7 pt when the box fits and 85.7 pt when it must shrink to 0.499 — the predicted factor, to three decimals. So a box smaller than the sheet is placed unscaled in the middle, which is the "enormous margins" report; a box larger than the sheet is scaled down, which is the "everything is tiny" report.
Which brings the Bootstrap 4 default back, because a3 is bigger than A4:
// Bootstrap 4.6.2, scss/_print.scss, shipped:
// @page { size: $print-page-size; } with $print-page-size: a3 !default;
a paragraph of 11px text, format 'A4', preferCSSPageSize off
no @page rule second line of text at y = 32.6pt
@page { size: a3 } second line of text at y = 23.1pt
23.1 / 32.6 = 0.709
595.9 / 841.9 = 0.708 <- A4 sheet / A3 box
@page { size: ledger } content x = 25.5 predicted (595.9 - 792*0.689)/2 = 25.2
CSS ledger is 11in x 17in PORTRAIT, so it does not flip.
An A3 layout on A4 paper is a uniform 70.8% reduction: 11 px body text arrives at about 7.8 px. Nothing is missing, nothing is clipped, and every measurement in the document is quietly wrong by the same factor — which is why this one survives review and gets reported by whoever has to read the printout.
Cause 4 — @page { margin } beats the margin option outright
Different property, same shape of problem, and worth knowing because the fix for causes 1–3
often involves editing an @page rule that also sets margins:
CSS @page margin page.pdf() margin option first glyph x
-------------------------------------------------------------------------
(no @page rule) { top:'25mm', left:'25mm' } 69.7 option applied
@page{margin:10mm} (none passed) 28.5
@page{margin:10mm} { top:'25mm', left:'25mm' } 28.5 CSS wins
@page{margin:0} { top:'25mm', left:'25mm' } 0.0 CSS wins
@page{margin:25mm} { top:'0', left:'0' } 71.2 CSS wins
Rows 2 and 3 are identical, which is the finding: once the CSS declares a page margin, the
margin option contributes nothing. Row 4 is the sharp edge —
@page { margin: 0 } is a common line in print stylesheets, and it silently zeroes the
margins you set in code. Row 1 is the control: with no @page rule, the option applies
normally.
Cause 5 — the size keyword you used may not exist
The CSS size descriptor accepts ten named sizes, and it is a shorter list than
most people assume. We asked the parser directly, which is more reliable than reading a
table:
st.textContent = `@page{size:${name}}`;
st.sheet.cssRules[0].style.getPropertyValue('size')
A3 -> a3 A6 -> (dropped)
A4 -> a4 A2 -> (dropped)
A5 -> a5 B6 -> (dropped)
B4 -> b4 tabloid -> (dropped)
B5 -> b5
JIS-B4 -> jis-b4 landscape -> landscape
JIS-B5 -> jis-b5 portrait -> portrait
letter -> letter auto -> auto
legal -> legal 21cm 29.7cm -> 21cm 29.7cm
ledger -> ledger
A6, A2, B6 and tabloid are dropped —
the declaration does not survive parsing, so the rule has no effect and you get the paper the
options asked for. MDN's list of accepted keywords is exactly the ten that survived here.
The confusion is understandable: Puppeteer's option table does accept
a0–a6 and tabloid, so format: 'a6' works
while @page{size:A6} does nothing. Two different vocabularies, one word apart.
A bonus: one document, several orientations
Because the page box is a CSS concept, it can vary per page — and, as with cause 1, that
survives preferCSSPageSize being off:
CSS prefer page 1 page 2
---------------------------------------------------------------------------------
@page :first { size: landscape } off 842.9x595.9 L 595.9x842.9 P
@page{size:A4} @page :first{size:A3} on 841.9x1191.1 595.0x841.9
@page big{size:A3 landscape} .big{page:big} off 595.9x842.9 842.9x595.9 L
@page big{size:A3 landscape} .big{page:big} on 595.9x842.9 1191.1x841.9
format: 'A4' throughout. Row 1 has mixed orientation with prefer OFF.
Row 1 produced a two-page PDF whose first page is landscape and whose second is portrait,
from @page :first { size: landscape } with the flag off. Rows 3 and 4 use a named
page — @page big { … } plus .big { page: big } — to turn one element's page
sideways. If you have been generating two PDFs and stitching them because you needed a
landscape table in a portrait report, this replaces that. It is the same mechanism that
bites you in cause 1, pointed somewhere useful.
While you are here: format: 'A4' is not one number
We noticed this while checking driver parity, and it is worth a paragraph because it quietly breaks pixel comparisons between two services:
MediaBox measured the library's own constant
---------------------------------------------------------------------------------
Playwright format:'A4' 595.92 x 842.88 pt a4: { width: 8.27,
height: 11.7 } (in)
puppeteer format:'A4' 595.92 x 841.92 pt a4: { in: { width: 8.2677,
height: 11.6929 } }
CSS @page{size:A4} + prefer ON 594.96 x 841.92 pt ISO 210 x 297 mm
either driver, 'Letter' 612.00 x 792.00 pt 8.5 x 11 in
The two libraries ship different inch tables for A4 — 11.7 vs 11.6929 — and the PDFs differ
by about 1 pt in height, roughly 0.34 mm. Neither output reproduces its own constant
exactly, so Chromium is doing some rounding of its own that we did not chase; what we can
report is the three measured MediaBoxes and the two constants they came from. Practical
consequences: nobody's printer cares, no ISO A4 tray will reject either, and a byte-for-byte
or hash comparison of "the same" A4 PDF across the two libraries will never match. The CSS
route (preferCSSPageSize with @page{size:A4}) is the one that lands
closest to 210 × 297 mm.
Puppeteer and Playwright: identical behaviour, different paper
Playwright 1.62.1 puppeteer-core 25.8.0
---------------------------------------------------------------------------
landscape:true alone 842.9 x 595.9 841.9 x 595.9
@page{size:landscape}, no option 842.9 x 595.9 841.9 x 595.9
@page{size:portrait} + landscape 595.9 x 842.9 595.9 x 841.9
@page{size:A5} prefer OFF x=88.5 y=126.4 x=88.5 y=125.6
@page{size:A5 landscape} pref OFF x=123.7 y=91.1 x=123.0 y=91.1
@page{size:A5 landscape} pref ON 595.0 x 420.0 595.0 x 420.0 identical
width/height + landscape:true 283.9 x 567.1 283.9 x 567.1 identical
@page{margin:25mm} + api margin 0 x=71.2 y=73.9 x=71.2 y=73.9 identical
Every behavioural row agrees. The numeric differences are all the A4 constant above,
showing up wherever the sheet came from the library's format table — and the three
rows where the geometry came from CSS or from explicit width/height are
identical to the decimal, which is the tidiest confirmation we could ask for: the disagreement
is in the libraries' paper tables, and the behaviour is Chromium's.
So a Puppeteer answer you found in a forum thread applies to your Playwright code, and neither library can fix any of this without diverging from what a browser does when it prints.
What to actually do
Pick one place to own the geometry, and make the other place shut up. Both directions work; mixing them is what produces the six symptoms at the top of this page.
// 1. neutralise whatever the page's stylesheet asks for, in the page itself:
await page.addStyleTag({
content: '@page { size: auto; margin: 0 }', // last rule wins the cascade
});
// 2. …then state the geometry once, in the API, and own it:
const bytes = await page.pdf({
format: 'A4',
landscape: true,
margin: { top: '15mm', bottom: '15mm', left: '12mm', right: '12mm' },
printBackground: true,
});
// or the other way round — put the geometry in CSS and let it win on purpose:
// @page { size: A4 landscape; margin: 15mm 12mm }
// page.pdf({ preferCSSPageSize: true, printBackground: true })
If you control the document, moving all geometry into CSS and setting
preferCSSPageSize: true is the cleaner half of that — one source of truth, visible in
the page, and print preview in a browser then shows you the truth. If you do not control the
document — you are rendering a customer's URL — the addStyleTag line is the reliable
one, because it appends last and the cascade favours it.
size: auto: it neutralises a size the way you want, and it is also
what lets the options through, but it does not undo a
@page { margin } elsewhere in the cascade — hence
margin: 0 in the same rule above. Set both, then set what you meant in the API.If you would rather not own this
This is a fixed cost: an afternoon, then twenty lines in a helper, then it never bothers you again. For most teams that is the right trade, especially since owning the browser means you can also pick your paper.
If handing the render to a service is more attractive, snapdok.io is one
option, and this particular article is a bad advertisement for us, so let us be straight about
it: we do not expose orientation or page size at all. Every PDF is A4, there
is no landscape parameter, no format, no margin and no
scale. If page geometry is the thing you are fighting, Playwright and WeasyPrint both
give you far more control than we do — see
the flag map for what each one exposes.
What is worth knowing, since it follows from the measurements above, is that our render path is the ordinary one and so the CSS route still reaches it. We reproduced our own call and measured it:
our own render call, from src/render.ts:
page.pdf({ format:'A4', printBackground:true, margin:{top:'0',…} })
— preferCSSPageSize is not set
so, measured against that exact option set:
no @page rule 595.9 x 842.9 A4 portrait
@page { size: landscape } 842.9 x 595.9 A4 LANDSCAPE
@page { size: A5 landscape } 842.9 x 595.9 A4 landscape, content inset 123.7pt
@page { margin: 25mm } 595.9 x 842.9 content inset 71.2pt
So @page { size: landscape } in your own stylesheet does produce landscape output
from us — not because we implemented an option, but because we did not set
preferCSSPageSize and Chromium behaves as documented above. The same applies to the
margin row, which is a caveat rather than a feature: a @page { margin } rule in your
page will override the zero margins we pass. If either of those matters to your document,
test it before you rely on it — and if you need the paper size itself to change, we are not
the tool for that today.
The short version
landscape: true is a request, not an instruction. A CSS @page rule in the
document outranks it in both directions, and preferCSSPageSize does not change
that — it only decides whether CSS also picks the paper size. The CSS page box lays
the document out regardless, so a mismatch shows up as a centred island (box smaller than the
sheet) or a uniform shrink (box larger, which is what Bootstrap 4's a3 default
does). @page { margin } beats the margin option outright. And with explicit
width/height, landscape swaps them rather than forcing anything.
Two commands settle any of it faster than reading: pdfinfo file.pdf for what you
got, and the CSSPageRule walk above for who asked for it.
Related, from the same measurements:
why your PDF header is not showing —
five causes, including the 1 px default font size inside header templates — and
every wkhtmltopdf flag and its 2026 equivalent,
which covers the page.pdf() defaults that differ from wkhtmltopdf's.
Sources, all read 2026-08-17:
Playwright's
page.pdf() reference — the wording quoted for
preferCSSPageSize, landscape and format, and their defaults ·
Puppeteer's
PDFOptions reference ·
MDN on
the CSS size descriptor — the ten named page sizes and the
landscape/portrait keywords ·
Bootstrap
4.6.2 _print.scss and
its
_variables.scss — the @page rule and the a3
default, quoted from the tagged source ·
Bootstrap
5.3.3 bootstrap.scss — no print partial among its imports.
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 near the top. The library paper constants are quoted from the installed packages
(playwright-core and puppeteer-core/lib/puppeteer/common/PDFOptions.js).