Preventing Table Row Splits Across PDF Page Breaks
Enforcing atomic row rendering is a specific control within the broader Table Pagination Strategies for Large Attribute Tables workflow. By default, PDF rendering engines prioritise vertical space efficiency: when a row’s content exceeds the remaining page height, the engine slices the row mid-cell and continues it on the next page. For GIS attribute tables this is destructive — multi-line WKT strings lose coordinate continuity, joined spatial attributes detach from their primary keys, and high-precision numeric fields lose header alignment. This guide shows exactly how to disable row splitting in ReportLab and in HTML-to-PDF pipelines, validate the output, and handle the edge cases unique to spatial data.
The diagram below shows the decision point where the rendering engine either fragments a row or pushes it whole to the next page.
Prerequisites
- Python 3.10+ with
reportlab≥ 3.6 installed (pip install reportlab) - For HTML pipelines:
weasyprint≥ 60 orpuppeteer(Node 18+) - Attribute data as a list of lists or a
pandasDataFrame; first row must be column headers - Familiarity with Dynamic Map & Data Embedding Workflows — specifically how table data flows from a GIS data source into a PDF layout object
Step-by-Step Implementation
Step 1 — Disable Row Splitting in ReportLab
The splitByRow parameter on the Table constructor is the single switch that controls fragmentation. Setting it to 0 makes every row an indivisible unit: if a row cannot fit in the remaining vertical space on the current page, the entire row moves to the next page. Pair it with repeatRows=1 so column headers re-render at the top of each continuation page.
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
from reportlab.lib import colors
from reportlab.lib.colors import HexColor
def build_atomic_table(
rows: list[list[str]],
col_widths: list[float],
) -> Table:
"""
Return a ReportLab Table with atomic row rendering.
Args:
rows: List of rows including a header row at index 0.
col_widths: Column widths in inches. Must sum to <= usable page width.
"""
table = Table(
rows,
colWidths=[w * inch for w in col_widths],
splitByRow=0, # Disable mid-row page splits
repeatRows=1, # Re-render header on each continuation page
)
table.setStyle(TableStyle([
# Header row
("BACKGROUND", (0, 0), (-1, 0), HexColor("#2C3E50")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 9),
("ALIGN", (0, 0), (-1, 0), "CENTER"),
("BOTTOMPADDING", (0, 0), (-1, 0), 6),
# Data rows
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS",(0, 1), (-1, -1), [colors.white, HexColor("#F8F9FA")]),
]))
return table
splitByRow=0 is the default-off safety switch — omitting it means the engine defaults to 1 (splitting enabled). There is no partial setting: the parameter accepts 0 (never split) or 1 (split when necessary).
Step 2 — Validate Row Heights Before Rendering
If a row with splitByRow=0 cannot fit on any page, ReportLab raises a LayoutError at build time rather than at content-authoring time. Pre-validate by estimating each row’s rendered height before calling doc.build(). The function below flags rows that will overflow the usable area.
from reportlab.pdfbase.pdfmetrics import stringWidth
def check_row_heights(
rows: list[list[str]],
col_widths: list[float],
usable_height: float,
font_name: str = "Helvetica",
font_size: float = 8.0,
cell_padding: float = 6.0,
) -> list[int]:
"""
Return indices of rows whose estimated height exceeds usable_height (in points).
col_widths and usable_height must be in points (1 inch = 72 points).
"""
oversize: list[int] = []
line_height = font_size * 1.2
for idx, row in enumerate(rows):
max_lines = 1
for cell, width_in in zip(row, col_widths):
cell_width_pt = width_in * 72 - cell_padding * 2
for line in str(cell).splitlines():
line_px = stringWidth(line, font_name, font_size)
# Account for word-wrapped lines
wrapped = max(1, int(line_px / cell_width_pt) + 1)
max_lines = max(max_lines, wrapped)
estimated_height = max_lines * line_height + cell_padding * 2
if estimated_height > usable_height:
oversize.append(idx)
return oversize
# Usage:
# usable = (A4[1] - 0.75*inch - 0.75*inch) # page height minus margins in points
# bad_rows = check_row_heights(rows[1:], col_widths_in, usable)
# if bad_rows:
# raise ValueError(f"Rows {bad_rows} exceed page height — truncate WKT or increase margins.")
Run this check in your CI pipeline before invoking doc.build(). For geometry columns containing WKT, truncate coordinate arrays to a fixed character limit and attach the full geometry to a companion GeoJSON file rather than embedding it in the PDF cell.
Step 3 — Apply CSS Fragmentation Properties for HTML Pipelines
When rendering PDFs from HTML via automated static map generation from GeoJSON or similar pipelines that use WeasyPrint, Puppeteer, or wkhtmltopdf, CSS controls row atomicity. Apply break-inside: avoid to tr elements within a @media print block so the rule only fires during PDF rendering and does not affect on-screen table layout.
/* Base table layout */
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 6px 8px;
border: 1px solid #cbd5e1;
font-size: 11px;
vertical-align: top;
}
/* Print-only fragmentation control */
@media print {
tr {
break-inside: avoid; /* W3C CSS Fragmentation Level 3 */
page-break-inside: avoid; /* Legacy fallback for wkhtmltopdf */
}
/* thead is repeated automatically by CSS Paged Media engines */
thead {
display: table-header-group;
}
}
Engine-specific behaviour:
- WeasyPrint and PrinceXML honour
break-inside: avoidper the CSS Fragmentation Module. They also repeat<thead>content on every continuation page automatically whendisplay: table-header-groupis set. - wkhtmltopdf uses a legacy QtWebKit renderer and requires
page-break-inside: avoid. The modernbreak-insideproperty is silently ignored. - Puppeteer / headless Chrome respects both properties. Confine the rule to
@media printto avoid interfering with on-screen scrollable containers.
Step 4 — Truncate Oversized Geometry Cells
The root cause of row-height overflow in GIS attribute tables is rarely the attribute data itself — it is the raw geometry field. A full WKT MULTIPOLYGON for a complex land parcel boundary can run to thousands of characters. Rather than embedding these in the PDF cell, truncate them to a short identifier and reference the full geometry in a separate downloadable file.
def truncate_geometry_field(
value: str,
max_chars: int = 60,
suffix: str = "…[see attached GeoJSON]",
) -> str:
"""Shorten WKT/GeoJSON geometry strings to a fixed display length."""
if len(value) <= max_chars:
return value
return value[:max_chars].rstrip() + " " + suffix
def prepare_rows_for_pdf(
records: list[dict],
geometry_keys: list[str] | None = None,
) -> list[list[str]]:
"""
Convert dicts to a list of string rows, truncating known geometry columns.
The first element of the returned list is a header row.
"""
if not records:
return []
geometry_keys = geometry_keys or ["geometry", "wkt", "geom", "shape"]
headers = list(records[0].keys())
result: list[list[str]] = [headers]
for rec in records:
row: list[str] = []
for key in headers:
val = str(rec.get(key, ""))
if key.lower() in geometry_keys:
val = truncate_geometry_field(val)
row.append(val)
result.append(row)
return result
Step 5 — Assemble the Document
Combine the components into a complete, runnable build function that handles both the ReportLab path and produces a pre-flight report of any rows that were truncated.
import logging
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate
logger = logging.getLogger(__name__)
def generate_spatial_attribute_pdf(
output_path: str | Path,
records: list[dict],
col_widths: list[float],
geometry_keys: list[str] | None = None,
top_margin: float = 0.75,
bottom_margin: float = 0.75,
) -> Path:
"""
Build a PDF from GIS attribute records with atomic row rendering.
Args:
output_path: Destination file path for the PDF.
records: List of dicts from a GeoDataFrame or query result.
col_widths: Per-column widths in inches. Must fit within page width.
geometry_keys: Column names containing WKT/GeoJSON to be truncated.
top_margin: Top margin in inches.
bottom_margin: Bottom margin in inches.
Returns:
Path to the generated PDF.
"""
output_path = Path(output_path)
rows = prepare_rows_for_pdf(records, geometry_keys)
if not rows:
raise ValueError("records must not be empty")
usable_height_pt = A4[1] - (top_margin + bottom_margin) * inch
oversize = check_row_heights(rows[1:], col_widths, usable_height_pt)
if oversize:
logger.warning(
"Rows %s exceed page height after truncation — "
"consider reducing font size or splitting into multiple tables.",
oversize,
)
doc = SimpleDocTemplate(
str(output_path),
pagesize=A4,
topMargin=top_margin * inch,
bottomMargin=bottom_margin * inch,
)
table = build_atomic_table(rows, col_widths)
doc.build([table])
logger.info("PDF written to %s (%d data rows)", output_path, len(rows) - 1)
return output_path
Key Parameters / Configuration Reference
| Parameter | Type | Default | Effect |
|---|---|---|---|
splitByRow (ReportLab) |
int |
1 |
0 = atomic rows; 1 = split permitted |
repeatRows (ReportLab) |
int |
0 |
Number of leading rows to repeat after each page break |
break-inside (CSS) |
keyword | auto |
avoid prevents row fragmentation in compliant engines |
page-break-inside (CSS) |
keyword | auto |
avoid for wkhtmltopdf / legacy WebKit |
display: table-header-group (CSS) |
keyword | table-row-group |
Forces <thead> to repeat on every CSS Paged Media page |
max_chars (truncation helper) |
int |
60 |
Maximum character length for geometry cell display |
Common Pitfalls
-
Omitting
splitByRow=0and wondering why rows still split. The parameter name is easy to overlook because ReportLab’s default is to split. Always set it explicitly in the constructor — a style command orTableStyleentry cannot override it. -
LayoutErroron untruncated WKT columns. AMULTIPOLYGONwith hundreds of vertices can render to a cell taller than an A4 page. The pre-flight check in Step 2 catches this at pipeline time rather than at render time, allowing you to truncate and retry without aborting the entire job. -
break-inside: avoidsilently ignored in wkhtmltopdf. Because wkhtmltopdf uses an older rendering engine, the modern property has no effect. Always includepage-break-inside: avoidas a fallback. Verify behaviour by generating a test PDF with a deliberately long row and inspecting the page boundary. -
<thead>not repeating in WeasyPrint. WeasyPrint repeats<thead>only when it is marked asdisplay: table-header-group. If you are generating the HTML programmatically through a Jinja2 templating workflow, ensure the loop that emits table rows wraps header cells in<thead>rather than placing them in the first<tr>of<tbody>.
Verification
Programmatic assertion (ReportLab)
After building the PDF, use PyMuPDF (fitz) to inspect text block positions and confirm that no cell value appears on two different pages:
import fitz # pip install pymupdf
def assert_no_split_rows(pdf_path: str, sentinel_values: list[str]) -> None:
"""
Raise AssertionError if any sentinel value from sentinel_values
appears on more than one page — which would indicate a split row.
"""
doc = fitz.open(pdf_path)
for value in sentinel_values:
pages_found: list[int] = []
for page_num, page in enumerate(doc, start=1):
if value in page.get_text():
pages_found.append(page_num)
assert len(pages_found) <= 1, (
f"Value '{value}' found on pages {pages_found} — row was split."
)
doc.close()
# Usage:
# assert_no_split_rows("spatial_report.pdf", ["P-1042", "P-2031"])
CI command
Add a smoke-test step to your build pipeline that generates a fixture PDF and runs the assertion:
python -m pytest tests/test_pdf_atomicity.py -v
The test module generates a table with a known multi-line WKT row and asserts it appears only once across the page set. This integrates naturally with the document architecture layout rules validation gates used across the reporting pipeline.
Related
- Table Pagination Strategies for Large Attribute Tables — parent guide covering chunking, header repetition, and row-height calculation across the full pagination workflow
- Iterating Through Shapefile Attributes in ReportLab — how to stream attribute rows from a shapefile into a ReportLab layout loop
- Syncing Chart.js Outputs to ReportLab Canvas — embedding chart elements alongside paginated attribute tables in the same document
- How to Set Exact Bleed Margins in WeasyPrint for GIS Maps — companion guide for controlling print margins when HTML-to-PDF row atomicity depends on correct page geometry