Margins and page size in HTML to PDF
There are three ways to say how big the page is and how far in the text starts: the renderer’s own options, a CSS @page rule, and padding on the body. They are usually described as alternatives. They are not, and one of the three does nothing at all.
Everything below was printed on Chromium 151.0.7922.34 and read back out of the finished PDF. Nothing here is about what should happen.
@page { size } is ignored, and it is the usual answer
Ask how to set the page size and the answer that comes back is a CSS rule. Printed through headless Chromium with the renderer given a format, that rule changes nothing:
the same document, printed twice
page box
format: "A4", no @page rule 210 x 297 mm
format: "A4", @page { size: A5 } 210 x 297 mmThe document asked for A5 and got A4, at full size, with no warning anywhere. The rule is not malformed and it is not overridden by a more specific rule. Chromium is told to prefer the size it was given over the one in the stylesheet, and that preference is off by default in every renderer that wraps it.
So a page that comes out the wrong size after adding @page was never going to come out any other way, and the time spent adjusting the rule is spent on something inert.
What does change the page size
The renderer’s own option, and nothing else. Given explicit dimensions instead of a named format, the page is those dimensions:
width and height instead of format
width: "148mm", height: "210mm" 148 x 210 mm
format: "A4" and width/height together 210 x 297 mmThe second line is worth its own sentence. Passing a format and a width and height is accepted, and the format wins: the dimensions are discarded without an error. A caller who sets both and sees A4 is not looking at a bug in their millimetres.
The two spellings of the same intent, one inert and one not:
@page {
size: A5;
}{
"options": {
"format": "A5"
}
}@page { margin } is the opposite: it wins
The same rule, one property along, behaves the other way round. Here is where the first glyph of the document actually lands:
where the first glyph lands, from the top left of the page
API margin 20mm, no CSS 19.8 mm from the left
API margin 0, no CSS 0 mm
API margin 20mm, @page { margin: 0 } 0 mm
API margin 20mm, @page { margin: 40mm } 40 mm
API margin 0, body { padding: 20mm } 20 mm
API margin 0, body { margin: 20mm } 20 mm
API margin 20mm, body { padding: 20mm } 39.8 mmThree things in that table are worth reading twice. @page { margin: 0 } beats an API margin of 20mm and puts the text hard against the edge. A CSS margin of 40mm beats an API margin of 20mm and gives 40. And padding on the body does not replace the API margin, it adds to it: 20 and 20 make 39.8.
That last line is the ordinary cause of margins that are too wide. Nothing is doubled by mistake; two mechanisms are both doing exactly what they were asked, and neither of them knows about the other.
margin and padding on the body land in the same place, so the choice between them is about what else is on the page rather than about the print.
Why one half of @page works and the other does not
The margin is a layout property. Chromium resolves it while laying the document out, the same as it resolves the margin on any other box, and the page it hands to the printer already has it. The size is not: the sheet exists before the document is laid out on it, so somebody has to decide it first, and by default that is the caller rather than the stylesheet.
Chromium can be told to take the size from the stylesheet instead. Doing so makes the format option a suggestion the document is free to ignore, which is a different promise from the one an API makes when it accepts a page format, so most renderers leave it off and few say so.
Checking it on your own file
The page box settles the size question by itself, and it sits uncompressed in the file, so reading it needs no PDF tooling at all:
node -e "const s=require('fs').readFileSync('invoice.pdf','latin1'), i=s.indexOf('MediaBox'); const n=s.slice(i+9, s.indexOf(']', i)).trim().split(' ').filter(Boolean).map(Number); console.log(Math.round(n[2]/72*25.4) + ' x ' + Math.round(n[3]/72*25.4) + ' mm')"210 x 297 mmThe box is stored in points at 72 to the inch, which is why the line converts. If the answer is not the size you asked for, it is coming from the renderer, and no stylesheet will move it.
If the box is right and the text is in the wrong place, the margin is coming from more than one of the three. Set the API margin to zero and print again: whatever indent survives is coming from the document.
How we handle this
PaperPony takes format, orientation and margin as options on the request, and the format is the one that decides the sheet. A @page rule in your document is not an error and will not be rejected; its size will not be used, and its margin will.
If a footer is what you are trying to make room for, the margin is not the setting that hides one: page numbers in a PDF from headless Chrome measures what does.