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

SymptomCauseFix
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 sheetMake 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: 0Remove 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 orientationDrop 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.

This is not a bug and it is not a Puppeteer or Playwright decision. Both libraries hand the same values to Chromium's 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 a0a6 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.

One caveat on 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).