CSS Grid Systems for Report Layouts
Float-based and table-driven document templates fracture the moment content volume changes — a legend that grows by two entries pushes the map frame off the page, and a multilingual annotation block overflows into the footer. CSS Grid eliminates that fragility by making layout intent explicit: named template areas, not positional offsets, govern where every spatial component lands. This section covers the complete pipeline for building deterministic, print-ready grid templates that survive automated data injection across hundreds of report runs.
Prerequisites
Before configuring grid templates, verify your stack meets these baseline requirements:
- Python 3.10+ with
weasyprint>=57.0andjinja2>=3.1 - PDF rendering backend:
WeasyPrintis strongly recommended for strict print-CSS compliance;playwright>=1.40is the alternative for headless Chromium capture - Spatial data serialization: GeoJSON, shapefile-derived CSVs, or raster extent metadata structured for template injection
- Baseline CSS knowledge: box model, flexbox fallback patterns, and
@pageat-rule syntax — all covered in Document Architecture & Layout Rules for Spatial Reports - Version control & CI/CD: Git-based template versioning with automated regression testing against reference PDFs
pip install "weasyprint>=57.0" "jinja2>=3.1" "playwright>=1.40"
python -m playwright install chromium
Pipeline Architecture
The diagram below shows the end-to-end data flow from raw GeoJSON through Jinja2 template rendering to the final paginated PDF:
@page rules and produces a paginated, print-ready PDF. A CI regression loop pixel-diffs each output against a stored reference.Step-by-Step Implementation
Step 1: Define Page Architecture and Grid Boundaries
Establish a fixed grid container mapped to physical page dimensions. Spatial reports must adhere to ISO 216 (A-series) or ANSI paper standards, so configure the @page rule first — renderers like WeasyPrint process @page declarations before any layout pass and will ignore conflicting declarations lower in the cascade.
@page {
size: A4 portrait;
margin: 20mm;
}
.report-grid {
display: grid; /* explicit — WeasyPrint does not infer this */
grid-template-columns: 1fr;
grid-template-rows: auto;
gap: 12px;
width: 100%;
max-width: 170mm; /* A4 (210mm) minus 2 × 20mm margins */
margin: 0 auto;
}
Cross-reference your target output against Print-Ready Page Sizing Standards for GIS Reports before finalising size and margin values — scaling artifacts during raster-to-vector conversion originate almost exclusively from a mismatch between declared page size and the renderer’s assumed canvas dimensions.
Step 2: Map Spatial Components to Named Grid Areas
Assign semantic grid-area identifiers to every report component: header, map-extent, legend, data-table, metadata, and footer. Use named areas rather than positional line numbers (grid-column: 1 / 3). Named areas decouple layout logic from content order, simplify multi-page template reuse, and keep Jinja2 conditional blocks readable.
/* Single-column baseline — renders correctly in both screen preview and print */
.report-grid {
grid-template-areas:
"header"
"map-extent"
"legend"
"data-table"
"metadata"
"footer";
}
.header { grid-area: header; }
.map-extent { grid-area: map-extent; }
.legend { grid-area: legend; }
.data-table { grid-area: data-table; }
.metadata { grid-area: metadata; }
.footer { grid-area: footer; }
For side-by-side components, extend to a two-column layout. Because PDF renderers apply print rules directly rather than through viewport-width media queries, use @media print instead of min-width:
@media print {
.report-grid {
grid-template-columns: 3fr 1fr;
grid-template-areas:
"header header"
"map-extent legend"
"data-table data-table"
"metadata footer";
}
}
This approach aligns with the W3C CSS Grid Layout Module Level 1 specification, which explicitly recommends named grid areas for maintainable, reproducible document structures.
Step 3: Bind Dynamic Spatial Data and Handle Map Image Scaling
Inject GeoJSON attributes, coordinate reference system (CRS) identifiers, and scale bars into the designated grid cells via Jinja2. Use object-fit and aspect-ratio on map images to prevent distortion when geographic extents vary across report runs.
<!-- spatial_report.html (Jinja2 template) -->
<div class="map-extent">
<img
src="{{ map_image_path }}"
alt="Spatial extent — {{ layer_name }}, CRS {{ crs_epsg }}"
class="map-image"
>
<div class="scale-bar" aria-label="Map scale: {{ scale_denominator }}">
<span class="scale-text">1:{{ scale_denominator }}</span>
</div>
</div>
.map-extent {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.map-image {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: contain;
background: #f4f4f4;
}
When rendering maps programmatically, generate images at 300 DPI minimum for print. If using Playwright for headless capture, set deviceScaleFactor: 3 in your launch options. For WeasyPrint, embed images as base64-encoded data URIs or use absolute file:// paths — relative paths resolve against the current working directory, not the HTML document, and will silently produce broken image slots.
Step 4: Apply Print-Specific Media Rules and Pagination Control
Wrap print-only overrides in @media print so they do not affect screen previews. Spatial reports frequently break across pages at problematic boundaries — a table row split in half, or a map frame divided by a page edge. Use break-inside, break-before, and break-after to enforce coherent pagination.
@media print {
.report-grid {
gap: 10px;
}
/* Prevent map frames and tables from splitting across page breaks */
.data-table,
.map-extent {
break-inside: avoid;
}
/* Force metadata onto a new page when it immediately follows a dense table */
.metadata {
break-before: page;
}
/* Suppress interactive UI chrome in the PDF output */
.no-print {
display: none !important;
}
}
For reports that mix portrait summary pages with landscape map spreads, see Handling Multi-Page Landscape vs Portrait Switches for a named-page implementation that avoids grid collapse during orientation transitions.
Before finalising your @page bleed and margin rules, consult Margin and Bleed Alignment in Automated PDFs to configure safe zones, trim marks, and gutter compensation. Always test using a 100% scale print preview — renderers commonly apply default scaling that shifts grid boundaries by 1–2 mm.
Step 5: Validate, Test, and Automate Regression
Automated spatial documents require deterministic validation. Implement a CI/CD pipeline that generates PDFs from sample datasets and compares them against baseline outputs using pixel-diff tools.
# generate_report.py — production WeasyPrint pipeline with logging
from __future__ import annotations
import logging
import pathlib
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML, CSS
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
TEMPLATE_DIR = pathlib.Path("templates")
OUTPUT_DIR = pathlib.Path("output")
STYLESHEET = pathlib.Path("static/report.css")
def generate(data: dict, output_name: str = "report.pdf") -> pathlib.Path:
env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=select_autoescape(["html"]),
)
template = env.get_template("spatial_report.html")
html_string = template.render(**data)
logger.info("Template rendered — %d chars", len(html_string))
output_path = OUTPUT_DIR / output_name
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
css = CSS(filename=str(STYLESHEET))
HTML(string=html_string, base_url=str(TEMPLATE_DIR)).write_pdf(
str(output_path),
stylesheets=[css],
)
logger.info("PDF written to %s", output_path)
return output_path
Track rendering drift across CSS updates, font substitutions, and backend version bumps. Store reference PDFs in Git LFS and run regression checks on every pull request:
# ci_regression.py — pixel-diff comparison against reference
import subprocess, sys, pathlib
REF_DIR = pathlib.Path("reference_pdfs")
OUT_DIR = pathlib.Path("output")
def check(filename: str) -> None:
ref = REF_DIR / filename
out = OUT_DIR / filename
result = subprocess.run(
["pdf-diff", "--dpi", "150", str(ref), str(out)],
capture_output=True,
)
if result.returncode != 0:
print(f"REGRESSION: {filename} differs from reference")
sys.exit(1)
print(f"PASS: {filename}")
Production-Ready Script
The following script wraps the full pipeline — Jinja2 render, WeasyPrint PDF generation, and optional regression check — in a single entry point suitable for CI/CD or batch generation:
#!/usr/bin/env python3
"""
generate_spatial_report.py
Batch-generate print-ready spatial report PDFs from GeoJSON metadata.
Usage: python generate_spatial_report.py --data features.geojson --out ./output
"""
from __future__ import annotations
import argparse
import json
import logging
import pathlib
import sys
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
from weasyprint import HTML, CSS
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
DEFAULTS = {
"template_dir": "templates",
"template_name": "spatial_report.html",
"stylesheet": "static/report.css",
"output_dir": "output",
}
def load_geojson(path: str | pathlib.Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def build_context(geojson: dict[str, Any]) -> dict[str, Any]:
"""Extract report-relevant fields from a GeoJSON FeatureCollection."""
features = geojson.get("features", [])
return {
"feature_count": len(features),
"layer_name": geojson.get("name", "Unnamed Layer"),
"crs_epsg": geojson.get("crs", {}).get("properties", {}).get("name", "Unknown"),
"features": features,
"map_image_path": "data:image/gif;base64,R0lGODlhAQABAAAAACw=", # placeholder
"scale_denominator": "50000",
}
def render(
context: dict[str, Any],
template_dir: str,
template_name: str,
stylesheet: str,
output_path: pathlib.Path,
) -> None:
env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(["html"]),
)
html_string = env.get_template(template_name).render(**context)
css = CSS(filename=stylesheet)
HTML(string=html_string, base_url=template_dir).write_pdf(
str(output_path),
stylesheets=[css],
)
logger.info("Written: %s", output_path)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate spatial report PDFs")
parser.add_argument("--data", required=True, help="Path to GeoJSON input")
parser.add_argument("--out", default=DEFAULTS["output_dir"])
parser.add_argument("--template", default=DEFAULTS["template_name"])
args = parser.parse_args(argv)
out_dir = pathlib.Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
geojson = load_geojson(args.data)
context = build_context(geojson)
out_file = out_dir / f"{context['layer_name'].replace(' ', '_')}_report.pdf"
render(
context,
template_dir=DEFAULTS["template_dir"],
template_name=args.template,
stylesheet=DEFAULTS["stylesheet"],
output_path=out_file,
)
return 0
if __name__ == "__main__":
sys.exit(main())
Edge Cases & Advanced Configuration
Multi-Language Spatial Labels
Spatial datasets often include multilingual place names, coordinate grids, and metadata fields. Use system font stacks or embed WOFF2 subsets to guarantee consistent rendering. Set lang attributes on individual grid containers — not just the <html> element — to trigger correct typographic ligatures and line-breaking rules for each locale. For in-depth font embedding and CJK support patterns, see Typography Mapping for Multi-Language Spatial Data.
Null and Absent Spatial Features
GeoJSON inputs from automated pipelines frequently contain null geometries or missing attribute fields. Guard every Jinja2 template block against absent values:
{% if features %}
<div class="data-table">
{% for f in features %}
<tr><td>{{ f.properties.get("name", "—") }}</td></tr>
{% endfor %}
</div>
{% else %}
<div class="data-table data-table--empty">No features in selected extent.</div>
{% endif %}
Use WeasyPrint’s --presentational-hints flag when rendering HTML that was produced by third-party GIS export tools — those tools often embed inline styles that conflict with your grid stylesheet.
Large-Dataset Performance
For reports containing more than 500 attribute rows or map tiles above 10 MB total, split generation into per-page HTML documents and merge with pypdf:
from pypdf import PdfWriter
writer = PdfWriter()
for page_html in paginate_features(features, page_size=50):
pdf_bytes = HTML(string=page_html).write_pdf()
writer.append_pages_from_reader(PdfReader(io.BytesIO(pdf_bytes)))
writer.write("merged_report.pdf")
Headless Rendering in Docker
When deploying WeasyPrint in a Docker container, install the Pango and Cairo shared libraries explicitly:
FROM python:3.12-slim
RUN apt-get update && apt-get install -y \
libpango-1.0-0 libpangocairo-1.0-0 \
libcairo2 libgdk-pixbuf-2.0-0 \
libffi-dev shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
Without libpangocairo, WeasyPrint fails silently on text rendering and produces PDFs with blank grid cells.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Grid layout collapses — all elements stack in a single column | Missing display: grid on .report-grid |
Add display: grid explicitly; WeasyPrint does not infer grid context from named-area child declarations alone |
| Map image appears at screen resolution (blurry in PDF) | Image generated at 72–96 DPI | Re-export at 300 DPI; use deviceScaleFactor: 3 in Playwright launch options |
| Page breaks split table rows or map frames | No break-inside rule on the affected element |
Add break-inside: avoid in an @media print block for .data-table and .map-extent |
WeasyPrint raises FileNotFoundError for images |
Relative image paths resolve against CWD, not the document | Pass base_url=str(TEMPLATE_DIR) to HTML() or use base64 data URIs |
| Grid cells overflow the page — content clips at the margin | max-width on .report-grid not accounting for full margin |
Recalculate: max-width = page_width − (left_margin + right_margin); verify with Print-Ready Page Sizing Standards |
| Orientation switch (portrait → landscape) resets grid | Named pages not assigned to containers | Use page: landscape; on the .report-grid--landscape variant — see Handling Multi-Page Landscape vs Portrait Switches |
Frequently Asked Questions
Why does my CSS Grid layout collapse inside WeasyPrint?
WeasyPrint requires an explicit display: grid declaration on the container and does not infer grid formatting context from child grid-area assignments alone. Add display: grid to .report-grid and verify you are running WeasyPrint 57+ which has full named-area support. Earlier versions treat grid areas as block-level boxes without the grid formatting context, causing all named children to stack in document order.
How do I switch between portrait and landscape pages in the same report?
Use CSS named pages (@page portrait and @page landscape) and assign them via the page property on individual grid containers. The Handling Multi-Page Landscape vs Portrait Switches guide covers the full implementation, including how to prevent grid container resets during orientation transitions that would otherwise collapse named-area bindings.
Why is my map image blurry in the generated PDF?
Screen-resolution images (72–96 DPI) are scaled up during PDF rendering. Generate map exports at 300 DPI minimum; when using Playwright for capture, set deviceScaleFactor: 3 in the launch options. For raster exports from QGIS or GDAL, pass --resolution 300 to the export command rather than relying on the default screen DPI setting.
Can I use CSS Grid in Playwright-rendered PDFs as well as WeasyPrint?
Yes. Playwright uses Chromium’s print engine, which has full Grid support including named areas. The key difference is that @media print is the correct hook for print rules in Playwright, whereas WeasyPrint also respects @page at-rules that Playwright ignores. For production pipelines, separate your @page bleed and margin rules from grid structure rules — the structure rules will work in both engines; the @page rules are WeasyPrint-specific.
Detailed Guides in This Section
- Handling Multi-Page Landscape vs Portrait Switches — implement CSS named pages to switch between portrait summary pages and landscape map spreads without breaking grid continuity.
- Mapping CSS Grid Areas to Multi-Panel Map Layouts — arrange a main map, inset, locator and legend as named grid areas that hold their proportions across print runs.
Related
- Print-Ready Page Sizing Standards for GIS Reports — ISO 216 and ANSI paper dimensions, bleed configuration, and scale calibration for spatial PDFs
- Margin and Bleed Alignment in Automated PDFs — safe zones, trim marks, and gutter compensation for commercial print delivery
- Typography Mapping for Multi-Language Spatial Data — font embedding, locale-aware line breaking, and WOFF2 subsetting for spatial reports
- Dynamic Legend Injection for Variable Datasets — programmatically populate the
legendgrid area from live GeoJSON symbology - Conditional Rendering for Missing Spatial Data — Jinja2 guard blocks that keep grid templates valid when features or attribute fields are absent
Parent: Document Architecture & Layout Rules for Spatial Reports
Conclusion
CSS Grid named areas transform an otherwise brittle sequence of float hacks into a layout contract that survives automated data injection at scale — the header, map-extent, legend, data-table, metadata, and footer areas remain structurally coherent regardless of content volume, CRS, or dataset size. Combined with strict @page configuration, break-inside pagination rules, a Jinja2 data-binding layer, and a CI pixel-diff regression loop, this pipeline gives GIS reporting teams the deterministic control that Document Architecture & Layout Rules for Spatial Reports demands across every automated output run.