Batch Exporting 300 DPI PDFs from QGIS with Python
Batch-exporting 300 DPI PDFs from QGIS with Python means driving QgsLayoutExporter inside a headless PyQGIS process: bootstrap a GUI-less QgsApplication, load the .qgz project, enable the print layout’s atlas over its coverage layer, and export each feature with PdfExportSettings.dpi=300 and forceVectorOutput=True so text and lines stay scalable while any raster content is sampled at print resolution. A per-sheet validation pass then reopens every PDF and confirms the embedded resolution and page dimensions before the batch is released to the press.
Core Algorithm and Workflow
A print PDF is not a single-resolution image, so “300 DPI” describes two separate guarantees. Vector elements — labels, strokes, symbol geometry — must remain resolution-independent, which forceVectorOutput=True enforces. Raster elements — a hillshade basemap, an aerial tile, any layer flattened for a blend mode — must be sampled at 300 DPI, which the dpi field controls. The batch workflow below produces both from a single atlas pass and verifies them afterward.
- Bootstrap headless. Construct
QgsApplication(None, False)with the GUI flag off, pointsetPrefixPathat the install, and callinitQgis(). On a server,QT_QPA_PLATFORM=offscreenkeeps Qt from probing for a display. - Load project and layout. Read the
.qgzwithQgsProject.instance().read(path)and fetch the print layout by name fromlayoutManager(). - Configure export settings. Set
dpi=300,forceVectorOutput=True, andrasterizeWholeImage=Falseso vectors stay crisp and only genuinely raster content is sampled at 300 DPI. This is the same resolution contract discussed across DPI and Resolution Management. - Iterate the atlas. Enable the atlas on its coverage layer and let
exportToPdfswrite one file per feature — every polygon in the coverage layer becomes one map sheet. - Validate. Reopen each PDF, assert 300 DPI and correct page dimensions, and only then release the batch.
Production-Ready Python Implementation
The script below is a complete, headless exporter. It runs under the QGIS Python interpreter (pinned to QGIS 3.34 LTR, PyQGIS API) and uses pikepdf (9.x) to read back each sheet’s page geometry for validation. Run it with the bundled interpreter — for example qgis_process environments or python3 with PYTHONPATH pointing at the QGIS python directory — so qgis.core resolves.
import os
import sys
from pathlib import Path
# Headless Qt: never attempt to open a display on a server.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (
QgsApplication,
QgsProject,
QgsLayoutExporter,
)
def start_qgis(prefix_path: str = "/usr") -> QgsApplication:
"""Boot a GUI-less QgsApplication suitable for a server or CI runner."""
QgsApplication.setPrefixPath(prefix_path, True)
qgs = QgsApplication([], False) # second arg False => no GUI
qgs.initQgis()
return qgs
def load_layout(project_path: str, layout_name: str):
"""Read a .qgz project and return the named print layout."""
project = QgsProject.instance()
if not project.read(project_path):
raise RuntimeError(f"Could not read project: {project_path}")
layout = project.layoutManager().layoutByName(layout_name)
if layout is None:
names = [l.name() for l in project.layoutManager().layouts()]
raise RuntimeError(
f"Layout '{layout_name}' not found. Available: {names}"
)
return layout
def pdf_settings(dpi: int = 300) -> QgsLayoutExporter.PdfExportSettings:
"""Print-ready vector PDF settings: crisp text, raster sampled at target DPI."""
settings = QgsLayoutExporter.PdfExportSettings()
settings.dpi = dpi
settings.forceVectorOutput = True # keep labels/strokes as vectors
settings.rasterizeWholeImage = False # do not flatten the whole page
settings.simplifyGeometries = False # no lossy geometry reduction
settings.appendGeoreference = True # embed a GeoPDF reference grid
return settings
def export_atlas(layout, out_dir: str, dpi: int = 300) -> list[Path]:
"""Iterate the layout's atlas and write one 300 DPI PDF per feature."""
Path(out_dir).mkdir(parents=True, exist_ok=True)
atlas = layout.atlas()
if not atlas.enabled():
atlas.setEnabled(True)
if atlas.coverageLayer() is None:
raise RuntimeError("Atlas has no coverage layer configured.")
exporter = QgsLayoutExporter(layout)
base_path = str(Path(out_dir) / "sheet") # atlas filename expr appends the rest
result, error = exporter.exportToPdfs(atlas, base_path, pdf_settings(dpi))
if result != QgsLayoutExporter.Success:
raise RuntimeError(f"Atlas export failed ({result}): {error}")
return sorted(Path(out_dir).glob("*.pdf"))
def validate_pdf(pdf_path: Path, layout, dpi: int = 300, tol_mm: float = 0.5) -> None:
"""Assert page size matches the layout paper and DPI metadata is intact.
A vector PDF has no single raster DPI, so we validate the *geometry*
contract: page dimensions must equal the layout's paper size to within
tol_mm. Any embedded raster image is checked against the target DPI.
"""
import pikepdf
# Expected paper size in millimetres from the first page item.
page = layout.pageCollection().page(0)
exp_w = page.pageSize().width()
exp_h = page.pageSize().height()
with pikepdf.open(pdf_path) as pdf:
mbox = pdf.pages[0].MediaBox
# PDF units are points (1/72 inch); convert to millimetres.
pts_to_mm = 25.4 / 72.0
got_w = float(mbox[2] - mbox[0]) * pts_to_mm
got_h = float(mbox[3] - mbox[1]) * pts_to_mm
if abs(got_w - exp_w) > tol_mm or abs(got_h - exp_h) > tol_mm:
raise AssertionError(
f"{pdf_path.name}: page {got_w:.1f}x{got_h:.1f} mm "
f"!= layout {exp_w:.1f}x{exp_h:.1f} mm"
)
# For any embedded raster XObject, verify effective sampling >= target.
for name, xobj in pdf.pages[0].images.items():
px_w = int(xobj.Width)
placed_in = got_w / 25.4 # worst case: image spans full width
eff_dpi = px_w / placed_in if placed_in else 0
if eff_dpi + 1 < dpi: # +1 absorbs rounding
raise AssertionError(
f"{pdf_path.name}: raster {name} ~{eff_dpi:.0f} DPI < {dpi}"
)
print(f"OK {pdf_path.name} {got_w:.1f}x{got_h:.1f} mm")
def main() -> int:
project_path = "atlas_project.qgz"
layout_name = "print_sheets"
out_dir = "export_300dpi"
dpi = 300
qgs = start_qgis()
try:
layout = load_layout(project_path, layout_name)
pdfs = export_atlas(layout, out_dir, dpi)
for pdf_path in pdfs:
validate_pdf(pdf_path, layout, dpi)
print(f"Exported and validated {len(pdfs)} sheet(s) into {out_dir}/")
return 0
finally:
qgs.exitQgis()
if __name__ == "__main__":
sys.exit(main())
The exportToPdfs (plural) call is the single most important line: it advances the atlas over every feature in the coverage layer and emits one file per sheet, naming each from the atlas filename expression configured in the project. For a single merged document, swap it for exporter.exportToPdf(atlas.layout(), path, settings) with the atlas enabled.
Performance Tuning and Cartographic Best Practices
- Keep
forceVectorOutput=Trueunless a layer forces rasterization. Vector text and strokes are resolution-independent and add negligible file weight. Thedpivalue only bites on raster layers and flattened blend regions — pairing it withforceVectorOutputgives the smallest print-ready file. The same trade-off drives High-Resolution Vector Export. - Rasterize selectively, not wholesale. A single layer with an opacity or multiply blend mode can trigger whole-page rasterization, silently dropping every other layer to the raster
dpi. Isolate such effects onto their own layer or pre-flatten them, and leaverasterizeWholeImage=False. - Boot QGIS once, export many.
initQgis()and project read are the expensive steps. Amortize them by looping multiple projects or layouts inside oneQgsApplicationlifetime rather than launching a fresh interpreter per sheet — the pattern that scales cleanly into Batch Queue Orchestration. - Validate dimensions, not just a DPI flag. Because a vector PDF has no global DPI, the reliable print contract is page geometry: assert the
MediaBoxequals the layout paper size within a sub-millimetre tolerance, and check any embedded raster’s effective sampling separately, as the script above does. - Convert to CMYK downstream, not in QGIS. QGIS emits RGB PDFs. If the press needs CMYK, run the validated sheets through a dedicated color pass rather than trusting an implicit conversion — see Color Profile and CMYK Conversion.
Integration and Next Steps
The exporter returns a sorted list of validated Path objects, which makes it easy to drop into a larger build. Wrap main() as a Celery task or a subprocess step and fan the coverage layer out across workers so each renders a slice of the atlas; because each QgsApplication process is independent, the only shared state is the output directory. Store the validated PDFs as build artifacts and let a downstream job assemble them into a single deliverable or hand them to the print queue.
When a sheet fails validation, the mismatch is almost always a resolution or unit problem rather than a QGIS bug — the same class of failure that appears when a headless rasterizer and its writer disagree on DPI, covered in Debugging DPI Mismatch in Headless Matplotlib Exports. Treat the validation gate as the source of truth: if the MediaBox disagrees with the layout paper size, fix the layout or the atlas page settings before touching the export code.
Frequently Asked Questions
Why does my PDF export ignore dpi=300 and stay at 96 DPI?
A vector PDF has no single global DPI; dpi only governs how rasterized elements are sampled. With forceVectorOutput=True and no layer forcing rasterization, text and geometry are resolution-independent — there is nothing at 96 DPI to find. When you do see 96, a raster layer or a blend/opacity effect triggered whole-page rasterization at the layout’s preview resolution. Set PdfExportSettings.dpi=300 and rasterizeWholeImage=False, and audit each layer’s blend mode.
How do I export one PDF per atlas feature instead of a single merged file?
Call QgsLayoutExporter.exportToPdfs(atlas, base_path, settings) — the plural method writes one file per atlas feature, naming each from the atlas filename expression. The singular exportToPdf with an active atlas produces one multi-page document instead. For fully manual control, drive atlas.first() / atlas.next() yourself and call exportToPdf with a distinct path on each iteration.
Do I need a running X server to export layouts headlessly?
No. QgsApplication([], False) initializes without a GUI, and setting QT_QPA_PLATFORM=offscreen tells Qt not to open a display. Layout rendering runs through the QPainter/QPrinter pipeline, which needs no windowing system once the offscreen platform plugin is active — which is exactly what makes this safe to run in CI or a container.
Related
- DPI and Resolution Management — the parent page covering how print and screen resolution contracts are defined and enforced across export pipelines.
- Debugging DPI Mismatch in Headless Matplotlib Exports — the companion for tracking down resolution disagreements when the renderer is Matplotlib rather than QGIS.
- Batch Queue Orchestration — scale this single-process exporter into a distributed queue that renders atlas sheets across many workers.
Back to DPI and Resolution Management