Converting QGIS Layout Templates to Automated CSS Grids

QGIS Print Composer layouts store every item’s position as absolute millimetre coordinates inside an XML file (.qpt). Migrating those coordinates to CSS Grid means parsing that XML, converting millimetre values to 1-based grid lines, and generating a display: grid container that a headless renderer can consume directly. This technique is a focused step within the Margin and Bleed Alignment in Automated PDFs workflow: once the CSS grid is generated you apply @page bleed rules to it and pipe it through WeasyPrint or Playwright for deterministic, version-controlled output.

The benefit over keeping a static PDF export is that the HTML/CSS template becomes a data-driven artefact. Map images, legends, scale bars, and title blocks are injected at render time by a templating engine, so a single layout definition produces hundreds of unique reports without touching the QGIS UI.


Architecture: QGIS Coordinate Space vs CSS Grid Space

QGIS Print Layouts use an absolute-positioning model. Every <LayoutItem> carries four attributes — x, y, width, height — measured in millimetres from the page’s top-left origin. CSS Grid, by contrast, is track-based: rows and columns are defined first, then items are placed into named cells using integer line numbers.

The conversion strategy treats the page as an evenly spaced grid of N columns and M rows, then maps each item’s bounding box to the nearest grid lines:

Text
col_start = floor(x / col_step) + 1
col_end   = ceil((x + width)  / col_step) + 1
row_start = floor(y / row_step) + 1
row_end   = ceil((y + height) / row_step) + 1

A 12 × 12 grid over A4 landscape (297 × 210 mm) gives a step of ~24.75 mm per column and ~17.5 mm per row — fine enough to reproduce typical composer layouts without information loss.

QGIS layout to CSS Grid coordinate mapping Left panel: QGIS composer page showing three items placed at absolute x/y coordinates in millimetres. Right panel: the same items mapped to named CSS grid areas on a 12-column grid using integer line numbers. QGIS Composer (absolute mm) CSS Grid (12 × 12 tracks) map frame x=5 y=5 w=180 h=150 mm legend x=192 y=5 mm title block x=5 y=162 w=265 h=28 mm map-frame grid-area: 1/1/8/9 legend 1/9/5/13 title-block grid-area: 9/1/11/13 c1 c2 c3 r1 r2 r3
Left: three QGIS composer items at absolute millimetre positions. Right: the same items mapped to a 12 × 12 CSS grid using integer line numbers derived from the coordinate normalisation formula.

Prerequisites

  • Python 3.10+ with the standard library only (no third-party XML parser needed for .qpt files).
  • A .qpt file exported from QGIS via Project → Export Layout as Template. .qgz files are ZIP archives — unzip them and locate the .qpt inside before parsing.
  • Working knowledge of CSS Grid Systems for Report Layouts, particularly grid-template-columns, grid-template-rows, and the grid-area shorthand.
  • A headless PDF renderer: weasyprint (pip install weasyprint) or Playwright (pip install playwright && playwright install chromium).

Step-by-Step Implementation

Step 1: Inspect the QPT XML Structure

Open the .qpt file in a text editor before writing any parser code. Every layout item is a <LayoutItem> element (QGIS 3.10+) or a <ComposerItem> element in older exports. The attributes you need are type, x, y, width, and height — all in millimetres:

XML
<LayoutItem type="65638" id="map_frame"
    x="5.00000000000001" y="5"
    width="180" height="150" .../>
<LayoutItem type="65639" id="legend"
    x="192" y="5"
    width="78" height="80" .../>

Structural items such as type="page" carry a zero id and describe the page canvas rather than content — skip them during parsing (see Step 2).

Step 2: Parse and Filter Layout Items

Python
import xml.etree.ElementTree as ET
import math
from pathlib import Path

# Types that represent the page canvas rather than content elements
_SKIP_TYPES = {"background", "page"}

def parse_qpt(qpt_path: str | Path) -> list[dict]:
    """Return a list of content item dicts from a QGIS layout template."""
    tree = ET.parse(qpt_path)
    root = tree.getroot()

    items = root.findall(".//LayoutItem") or root.findall(".//ComposerItem")
    if not items:
        raise ValueError(
            f"{qpt_path}: no LayoutItem or ComposerItem elements found. "
            "Confirm the file is a valid QGIS 3 layout template."
        )

    parsed: list[dict] = []
    for item in items:
        raw_type = item.get("type", "unknown").lower().replace(" ", "-")
        if raw_type in _SKIP_TYPES:
            continue

        width  = float(item.get("width",  0))
        height = float(item.get("height", 0))
        if width == 0 or height == 0:
            continue  # zero-size items are invisible; skip them

        parsed.append({
            "id":     item.get("id") or item.get("uuid", f"item-{len(parsed)}"),
            "type":   raw_type,
            "x":      float(item.get("x", 0)),
            "y":      float(item.get("y", 0)),
            "width":  width,
            "height": height,
        })
    return parsed

The id attribute in modern QGIS exports matches the label you see in the Items panel — reusing it as a CSS class name keeps the generated markup self-documenting.

Step 3: Normalise Millimetre Coordinates to Grid Lines

Python
def mm_to_grid_lines(
    x: float, y: float,
    width: float, height: float,
    page_w_mm: float, page_h_mm: float,
    cols: int = 12, rows: int = 12,
) -> tuple[int, int, int, int]:
    """
    Returns (row_start, col_start, row_end, col_end) — 1-based, inclusive start /
    exclusive end, matching CSS grid-area shorthand order.
    """
    col_step = page_w_mm / cols
    row_step = page_h_mm / rows

    col_start = max(1, math.floor(x / col_step) + 1)
    row_start = max(1, math.floor(y / row_step) + 1)
    col_end   = min(cols + 1, math.ceil((x + width)  / col_step) + 1)
    row_end   = min(rows + 1, math.ceil((y + height) / row_step) + 1)

    # Ensure every item spans at least one track
    if col_end <= col_start:
        col_end = col_start + 1
    if row_end <= row_start:
        row_end = row_start + 1

    return row_start, col_start, row_end, col_end

The max/min clamps prevent items that bleed slightly off-page from generating out-of-range line numbers. The guard at the end catches items smaller than one track step that would otherwise collapse to a zero-span cell.

Step 4: Generate the CSS Grid HTML Template

Python
def build_grid_html(
    items: list[dict],
    page_w_mm: float = 297,
    page_h_mm: float = 210,
    cols: int = 12,
    rows: int = 12,
    bleed_mm: float = 3.0,
) -> str:
    """
    Produce a complete HTML/CSS document whose grid matches the QGIS layout.
    Use explicit mm tracks (not fr) for print-accurate PDF rendering.
    """
    col_track = f"{page_w_mm / cols:.4f}mm"
    row_track = f"{page_h_mm / rows:.4f}mm"

    css_rules: list[str] = []
    grid_divs: list[str] = []

    for item in items:
        rs, cs, re, ce = mm_to_grid_lines(
            item["x"], item["y"], item["width"], item["height"],
            page_w_mm, page_h_mm, cols, rows,
        )
        safe_id = item["id"].replace(" ", "-").replace("/", "-")
        cls = f"qgis-{safe_id}"
        css_rules.append(
            f".{cls} {{ grid-area: {rs} / {cs} / {re} / {ce}; overflow: hidden; }}"
        )
        grid_divs.append(
            f'  <div class="{cls}" data-type="{item["type"]}">'
            f"<!-- {item['id']} --></div>"
        )

    css_block = "\n        ".join(css_rules)
    divs_block = "\n".join(grid_divs)

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    @page {{
      size: {page_w_mm}mm {page_h_mm}mm;
      margin: 0;
      bleed: {bleed_mm}mm;
      marks: crop;
    }}
    body {{
      margin: 0;
      padding: 0;
    }}
    .qgis-layout {{
      display: grid;
      grid-template-columns: repeat({cols}, {col_track});
      grid-template-rows:    repeat({rows}, {row_track});
      width:  {page_w_mm}mm;
      height: {page_h_mm}mm;
    }}
    {css_block}
  </style>
</head>
<body>
  <div class="qgis-layout">
{divs_block}
  </div>
</body>
</html>"""

Note that @page uses bleed: and marks: crop from the W3C CSS Paged Media Module Level 3 specification. WeasyPrint 60+ and PrinceXML both honour these properties; they ensure the output satisfies the same bleed envelope described in the Margin and Bleed Alignment in Automated PDFs guide.

Step 5: Render to PDF with WeasyPrint

Python
from weasyprint import HTML

def render_pdf(html_string: str, output_path: str | Path) -> None:
    """Write a PDF from the generated HTML grid template."""
    HTML(string=html_string).write_pdf(str(output_path))
    print(f"PDF written → {output_path}")


# Full pipeline entry point
if __name__ == "__main__":
    import sys
    qpt_file   = Path(sys.argv[1])          # e.g. my_report.qpt
    output_pdf = qpt_file.with_suffix(".pdf")

    items = parse_qpt(qpt_file)
    html  = build_grid_html(items, page_w_mm=297, page_h_mm=210)
    render_pdf(html, output_pdf)

Run with:

Bash
python convert_qpt.py my_report.qpt
# → my_report.pdf

Key Parameters / Configuration Reference

Parameter Type Default Effect
page_w_mm float 297 Page width in millimetres; must match QGIS canvas setting
page_h_mm float 210 Page height in millimetres; must match QGIS canvas setting
cols int 12 Number of CSS grid columns; increase for finer spatial resolution
rows int 12 Number of CSS grid rows; increase for finer spatial resolution
bleed_mm float 3.0 Bleed extension beyond trim line; 3–5 mm is standard for commercial print
col_track derived 24.75mm Physical width of each column track (page_w_mm / cols)
row_track derived 17.5mm Physical height of each row track (page_h_mm / rows)

Using explicit mm tracks rather than 1fr is deliberate: fractional units tell the renderer to fill available space, which may differ from physical page dimensions if the renderer adds its own margins. Hard mm values anchor every track to a known physical size.


Common Pitfalls

  • White gaps between grid cells. Floating-point rounding in floor/ceil can leave a sub-millimetre gap when adjacent items do not perfectly tile. Fix: increase grid resolution (cols=24, rows=24) so the step size is smaller than the smallest gap in the original layout.

  • Items using fr units drift off their coordinates. fr distributes remaining space after fixed items are placed, so the total can deviate from your page width. Switch all tracks to explicit mm values (see col_track / row_track in Step 4).

  • Legacy .qgz files return no items. A .qgz is a ZIP archive. Extract it with zipfile.ZipFile before passing the inner .qpt to parse_qpt. The parser raises ValueError with a clear message if no items are found, making this easy to diagnose.

  • QGIS atlas data-defined positions break the grid. Atlas expressions like @atlas_feature_id inside x or y attributes produce non-numeric strings. Wrap coordinate extraction in try: float(...) except ValueError: continue to skip data-defined items and handle them with absolute CSS positioning instead.


Frequently Asked Questions

Why does my CSS grid output have white gaps that did not appear in the QGIS composer?

Rounding during mm-to-fr conversion leaves sub-millimetre gaps between adjacent tracks. Switch from fractional units to explicit mm tracks — for example repeat(12, 24.75mm) — so track widths are tied directly to physical page dimensions. If gaps persist at finer resolutions, verify that adjacent items in the original QGIS layout share an exact edge (select both in the Items panel and check that the right edge coordinate of the left item equals the left edge of the right item).

How do I handle QGIS items that overlap each other in the composer?

CSS Grid allows cells to overlap when multiple items share the same grid-area. Assign explicit z-index values to each overlapping element, mirroring the stack order visible in the QGIS Items panel (higher z-index items appear on top). For a map frame overlaid by a north arrow or scale bar, the north arrow <div> needs z-index: 2 while the map <div> sits at z-index: 1.

Does this approach work with QGIS Atlas layouts?

Yes. Export the atlas coverage layer as GeoJSON and inject each feature’s attribute data into the HTML template at render time via Jinja2. The grid structure remains fixed while map images and attribute values change per iteration. For atlas outputs that alternate between landscape map sheets and portrait attribute pages, see Handling Multi-Page Landscape vs Portrait Switches.


Verification

After rendering, confirm the CSS grid faithfully reproduces the QGIS template:

Python
import subprocess, sys

def pixel_diff(expected_png: str, actual_pdf: str, threshold: float = 0.01) -> bool:
    """
    Convert the first page of actual_pdf to PNG with pdftoppm,
    then compare against expected_png using ImageMagick compare.
    Returns True if mean pixel error is below threshold (0–1 scale).
    """
    tmp_png = "/tmp/rendered-page-1.png"
    subprocess.run(
        ["pdftoppm", "-r", "150", "-png", "-singlefile", actual_pdf, "/tmp/rendered"],
        check=True,
    )
    result = subprocess.run(
        ["magick", "compare", "-metric", "MAE", expected_png, tmp_png, "/dev/null"],
        capture_output=True, text=True,
    )
    # ImageMagick prints MAE on stderr as "N (M)" where M is 0–1
    try:
        score = float(result.stderr.split("(")[1].rstrip(")"))
    except (IndexError, ValueError):
        score = 1.0
    print(f"Pixel diff score: {score:.4f} (threshold {threshold})")
    return score < threshold


# Usage in CI
ok = pixel_diff("reference-export.png", "my_report.pdf")
sys.exit(0 if ok else 1)

Run pdftoppm and magick (ImageMagick 7) from the same CI environment that produces the final PDF to avoid DPI or colour-profile differences. A threshold of 0.01 (1 % mean absolute error) is a practical starting point for layout regression tests; tighten it to 0.005 once your pipeline is stable.