Prevent Page Breaks Inside Tables and Charts in HTML-to-PDF

Updated October 27, 2026 ยท 7 min read

Chromium does not know which parts of your document belong together. A table can split after the third row, a chart can land on one page while its commentary lands on the next, and a totals block can sit alone on the final page. CSS helps with fixed blocks, but dynamic content changes the layout before you can see it.

The practical fix is a combination: use print CSS to keep atomic blocks together, then run a spatial test case that detects the splits CSS cannot prevent. If a check fails, return a patch, and the engine re-renders before delivering the PDF.

1. Start with print CSS

Use break-inside: avoid for elements that should never split: rows, totals, signature blocks, and short commentary sections.

.chart-card,
.summary,
.signature-block,
tr {
  break-inside: avoid;
  page-break-inside: avoid;
}

This prevents a row or a totals block from being cut in half. It does not keep a chart and a separate commentary element on the same page, and it cannot react when a long address pushes the totals onto page 2.

2. Add a spatial check

The pages array on every node tells you which physical pages it occupies. Compare the pages of dependent elements:

const chart = doc.querySelector('#kpi-chart');
const commentary = doc.querySelector('#commentary');

if (!chart || !commentary) {
  return { success: false, reason: 'Required layout elements are missing' };
}

if (chart.pages[0] !== commentary.pages[0]) {
  return {
    reason: 'Chart and commentary split across pages',
    patch: { compactKpis: true }
  };
}

return { success: true };

The patch is merged into the template data and the document is re-rendered. A common patch is a class that reduces KPI card height, chart height, or table row padding just enough to keep the dependent block together.

3. Make the template respond to the patch

<section class="exec-section{% if compactKpis %} compact{% endif %}">
  <div class="kpi-grid">...</div>
  <div class="chart-card" id="kpi-chart">...</div>
  <div class="commentary" id="commentary">...</div>
</section>

<style>
  .exec-section.compact .kpi-card { padding: 10px 14px; }
  .exec-section.compact .chart { height: 330px; }
</style>

For tables, the same pattern works: return { patch: { compactRows: true } } and reduce td { padding } or switch to a smaller font. For a totals block, reserve fixed space or force a page break before the summary when the last row is too close to the footer.

4. Verify the final layout

A patch is only useful if the re-render actually fixes the layout. Keep the check in the test case so the same script verifies the final attempt. If the elements are still split after the compact patch, return success: false with a reason; that becomes a Tier 2 webhook event instead of a silent broken PDF.

The Q3 report example and the invoice example show both paths: a small in-flight patch and a structural webhook fallback.

Common mistakes

  • Putting break-inside: avoid on a block that is taller than a page; it will split anyway.
  • Comparing pages[0] when a node spans multiple pages. Check the full pages array for long tables.
  • Compacting too much. Measure the final layout, not just the first attempt.
  • Using a patch on a raw URL. Patching requires a template (htmlContent + data or templateId).

FAQ

Does break-inside: avoid work in Chromium?

Yes for block-level elements and table rows, but it cannot keep two sibling blocks together or respond to dynamic content that changes pagination.

Can I keep a whole table on one page?

Add break-inside: avoid to the table only if it fits on one page. For long tables, keep rows atomic and repeat the header with thead { display: table-header-group; }.

How many retries are available?

Up to two internal retries (three total attempts) per generation. If the layout still fails, the request returns 422 or a webhook failure payload.

Test your page breaks

Generate a PDF with a test case that checks your chart, table, and totals block.

Start a free trial