PaperPony

Table rows splitting across pages in a PDF

Start by checking whether it is happening. A long table printed by headless Chrome usually does not have a single row cut in half, and the advice everyone reaches for first fixes something that was never broken.

Sixty ordinary rows, measured
60 rows, two lines each, A4, 20mm top and bottom margins

  no extra CSS          4 pages   0 rows cut   headings on pages 1,2,3,4
  break-inside: avoid   4 pages   0 rows cut   headings on pages 1,2,3,4

Same page count, no rows cut, with and without the declaration. If your table looks like that one, the problem you came here for is somewhere else: a heading stranded at the foot of a page, a total separated from the rows it sums, or a row that has moved rather than been broken. All three look like splitting and none of them is.

Every figure below came out of a rendered PDF read back page by page, on Chromium 151.0.7922.34, A4 with 20mm top and bottom margins. The version matters more than usual here, because one of the answers is that Chromium already does for you what you were about to ask it to do, and that is its decision rather than a standard.

How to tell, on your own PDF

Read the text out of the file instead of scrolling the pages. Poppler prints a form feed between pages, so a row that was cut has one part before it and the rest after:

poppler-utils, and it reads any PDF
pdftotext -layout invoice.pdf -
What a cut row looks like
...
  41  Item 41 first line          512.50
      Item 41 second line
  42  Item 42 first line          525.00
<page break, a form feed in the output>
      Item 42 second line
  43  Item 43 first line          537.50

Row 42 is the one to notice. If every row keeps its lines together and the break falls between two of them, nothing is being split, whatever the page looks like. That is the check the rest of this page is built on, run the same way against the same file.

A row is only cut when it cannot fit

Chromium moves a row to the next page when there is not enough room left for it. It breaks a row only when moving it will not help, which means when the row is taller than the space a page can offer. So the height of one row decides everything, and the length of the table decides nothing.

One variable, four values
one row in three made taller, everything else identical

  row height      as written              break-inside: avoid
  2 lines         2 pages,  0 cut         2 pages,  0 cut
  10 lines        3 pages,  1 cut         3 pages,  0 cut
  25 lines        5 pages,  3 cut         8 pages,  0 cut
  60 lines       10 pages,  8 cut        16 pages,  8 cut

At two lines a row always fits and is always moved. At ten lines it fits on a fresh page but not always in what is left of the current one, so one row in the whole document got broken. By twenty-five lines it happens three times. At sixty lines the row is taller than the page itself, and no amount of moving can save it.

This is why the symptom is so hard to reproduce from a bug report. Two documents with the same number of rows behave completely differently if one of them has a description field that occasionally runs long.

You do not need to ask for the headings to repeat

The most common answer to this question is to add thead { display: table-header-group }. It changes nothing, because that is already the value.

Proved by taking it away
the same 4-page table, one declaration changed

  thead untouched                    headings on pages 1,2,3,4
  thead { display: table-row-group }  headings on page 1

The four-page table carries its column headings on all four pages with no stylesheet asking for it. Force the other value and they collapse to page one, which is what makes this a measurement rather than an assumption.

There is one case where writing it out earns its place: a stylesheet somewhere else has already set display on thead, usually to block as part of a responsive table pattern, and the repeat stopped when that rule arrived rather than when the table grew.

The declaration that does work, and what it costs

On the body rows, not the table
tbody tr {
  break-inside: avoid;
}

On the twenty-five line document that is three cut rows down to none. It is also five pages up to eight.

The cost is structural rather than a surprise. Keeping a tall row whole means leaving the bottom of a page empty whenever the next row will not fit, and a document with many tall rows leaves that gap many times. Sixty percent longer on the document measured here, which is a real number for a real invoice and worth knowing before it is applied to a run of a thousand.

Apply it to tbody tr rather than to tr, or the header row gets it too and cannot be repeated cleanly.

Above one page it stops working and keeps charging

This is the part that is missing from every answer to this question, and it is the case most likely to be yours, because a row nobody expected to be tall is exactly the row that breaks a layout.

At sixty lines per tall row the declaration changes the number of cut rows from eight to eight. It changes the document from ten pages to sixteen.

A break has to happen somewhere: the content does not fit on any page, so the browser ignores the request and breaks it anyway. What the browser does not ignore is the other half of the instruction, which is to start the row on a fresh page. So every tall row gets a page of its own to begin on, six extra pages appear, and not one of the breaks you were trying to prevent goes away.

The six pages come back if you stop asking for the impossible on the rows that cannot deliver it:

Exempt the rows that will not fit
tbody tr { break-inside: avoid; }

/* Let the ones that cannot fit be broken rather than pushed. */
tbody tr.long { break-inside: auto; }
The same document, measured
the 60-line document, three ways

  avoid on every row                16 pages   8 cut
  avoid, auto on the long rows      10 pages   8 cut
  avoid, auto on the long cells     16 pages   8 cut

Sixteen pages down to ten, with the eight breaks that were always going to happen. Note the third row of that table, because it is the version most people would write: the same exemption on the cell instead of the row does nothing at all. Whether a row may be broken is decided on the row.

The reliable fix above the CSS is to stop the row being that tall. A description that runs to sixty lines is usually a field that should have been truncated, a list that should have been its own table, or an address that arrived with newlines in it.

Three things that look like this and are not

A heading alone at the foot of a page. Nothing was split. Ask for it to stay with what follows: h2 { break-after: avoid }.

A totals block separated from its rows. The same declaration as the rows, on the block that has to stay together.

A row that moved. The bottom of a page is empty and the row is intact on the next one. That is the browser doing its job, and forcing it not to is how a document ends up with a page break in the middle of a sentence somewhere else.

How we handle this

PaperPony renders PDFs from HTML through an API, on the Chromium these figures were measured on, with the page size and margins used above as the defaults. The CSS on this page is yours to write and behaves in our renderer the way it behaves in yours, which is the point of measuring it rather than describing it.

The template guide carries these declarations alongside the ones for headers, footers and fonts, and the invoice recipe is a complete document with them already in it. The invoice generator needs no account and runs the same renderer, so it is the quickest way to see how a table of yours breaks.