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.0 and jinja2>=3.1
  • PDF rendering backend: WeasyPrint is strongly recommended for strict print-CSS compliance; playwright>=1.40 is 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 @page at-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
Bash
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:

CSS Grid Spatial Report Pipeline Architecture Data flow from GeoJSON through Jinja2 and CSS Grid to a paginated PDF via WeasyPrint GeoJSON / Spatial Data extents, metadata Jinja2 Context Build template.render() CSS Grid named areas + @page HTML Output grid containers WeasyPrint Renderer paginate + layout PDF paginated print-ready CI regression: pixel-diff against reference PDF STEP 1 STEP 2 STEP 3–4 STEP 5
End-to-end pipeline: GeoJSON metadata populates a Jinja2 context, which renders into a CSS Grid HTML document; WeasyPrint applies @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.

CSS
@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.

CSS
/* 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:

CSS
@media print {
  .report-grid {
    grid-template-columns: 3fr 1fr;
    grid-template-areas:
      "header     header"
      "map-extent legend"
      "data-table data-table"
      "metadata   footer";
  }
}
Two-Column Named-Area CSS Grid for Spatial Reports Grid with header spanning full width, map-extent (3fr) next to legend (1fr), full-width data-table, then metadata next to footer header grid-column: 1 / -1 map-extent 3fr legend 1fr data-table grid-column: 1 / -1 metadata 3fr footer 1fr
The named-area grid produced by the rule above. Components reflow into these regions automatically — changing content volume or data density never breaks the structural arrangement.

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.

HTML
<!-- 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>
CSS
.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.

CSS
@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.

Python
# 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:

Python
# 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:

Python
#!/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:

HTML
{% 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:

Python
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:

DOCKERFILE
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


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.