Exporting Publication Quality Map Figures

Size the figure to its destination, read only the pixels that fit, and export with fonts embedded:

import matplotlib as mpl

mpl.rcParams["pdf.fonttype"] = 42          # embed TrueType, do not reference
mpl.rcParams["savefig.bbox"] = "tight"

fig.set_size_inches(3.5, 3.5)              # a single journal column
fig.savefig("figure3.pdf")                 # vector axes, raster image
fig.savefig("figure3.png", dpi=300)        # raster fallback

Almost every “the figure looks wrong in the published version” problem is one of those three lines. This page belongs to rendering rasters with matplotlib in Visualization, Tiling & Web Delivery.


The Pixel Budget

A figure can only show as many raster pixels as its printed size and resolution allow. Exceeding that budget wastes memory and file size; falling short of it produces a visibly soft image.

How many pixels a figure can actually show A three and a half inch column at 300 dpi shows 1,050 pixels across. A seven inch full-width figure at 300 dpi shows 2,100. A ten inch slide at 150 dpi shows 1,500. In every case a 10,980 pixel scene must be decimated by a factor of five or more, so reading it at full resolution is wasted work. Figure width x dpi = pixels available 3.5 in column @ 300 1,050 px — decimate a scene by 10 7 in full width @ 300 2,100 px — decimate by 5 10 in slide @ 150 1,500 px — decimate by 7 A 10,980 px scene never needs to be read in full for a figure.

The practical rule is to compute target_px = width_inches * dpi and pass that to the decimated read described in plotting a GeoTIFF with correct extent and axes. Reading a little more than the budget — say 1.5 times — gives some headroom for cropping without waste.


Environment & Setup

Package Version pin Used for
matplotlib >=3.8 Figure sizing, annotation, vector and raster export
rasterio >=1.3.0 Decimated reads sized to the pixel budget
numpy >=1.23 Stretch arithmetic
pip install "matplotlib>=3.8" "rasterio>=1.3.0" "numpy>=1.23"

Complete Working Example

import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import rasterio
from matplotlib.patches import Rectangle

mpl.rcParams.update({
    "pdf.fonttype": 42,        # embed TrueType fonts in PDF
    "ps.fonttype": 42,
    "font.size": 8,            # journal figures are small; 8-9 pt reads well
    "axes.linewidth": 0.6,
    "savefig.bbox": "tight",
    "savefig.pad_inches": 0.02,
})


def publication_figure(path: str, out_stem: str, *, width_in: float = 3.5,
                       dpi: int = 300, cmap: str = "YlGn",
                       limits: tuple[float, float] | None = None,
                       scale_km: float = 5.0):
    target_px = int(width_in * dpi * 1.5)          # a little headroom

    with rasterio.open(path) as src:
        factor = max(1, int(max(src.width, src.height) / target_px))
        arr = src.read(1, out_shape=(max(1, src.height // factor),
                                     max(1, src.width // factor)), masked=True)
        b = src.bounds
    extent = (b.left / 1000, b.right / 1000, b.bottom / 1000, b.top / 1000)

    vmin, vmax = limits or np.percentile(arr.compressed(), [2, 98])

    fig, ax = plt.subplots(figsize=(width_in, width_in))
    im = ax.imshow(arr, extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
                   interpolation="nearest")
    ax.set_aspect("equal")
    ax.ticklabel_format(style="plain")
    ax.set_xlabel("Easting (km)")
    ax.set_ylabel("Northing (km)")

    # Scale bar in data coordinates (km), anchored bottom-left
    x0, x1 = ax.get_xlim()
    y0, y1 = ax.get_ylim()
    bx, by = x0 + 0.07 * (x1 - x0), y0 + 0.07 * (y1 - y0)
    ax.add_patch(Rectangle((bx, by), scale_km, 0.008 * (y1 - y0),
                           facecolor="#0f3460", edgecolor="white", linewidth=0.5))
    ax.text(bx + scale_km / 2, by + 0.02 * (y1 - y0), f"{scale_km:g} km",
            ha="center", va="bottom", fontsize=7)

    # North arrow: the raster is north-up, so this is simply "up"
    ax.annotate("N", xy=(0.94, 0.94), xytext=(0.94, 0.86),
                xycoords="axes fraction", textcoords="axes fraction",
                ha="center", fontsize=8, fontweight="bold",
                arrowprops=dict(arrowstyle="-|>", color="#0f3460", linewidth=0.8))

    cb = fig.colorbar(im, ax=ax, shrink=0.7, pad=0.02, fraction=0.045)
    cb.outline.set_linewidth(0.6)
    cb.set_label("NDVI")

    fig.savefig(f"{out_stem}.pdf")
    fig.savefig(f"{out_stem}.png", dpi=dpi)
    plt.close(fig)

Setting font.size to 8 before creating the figure is more important than it looks. Matplotlib’s default of 10 points is sized for a figure viewed at its nominal size; a 3.5-inch figure scaled into a column keeps its physical text size, so the defaults are usually right — but a figure created at 8 inches and shrunk to 3.5 has text at less than half the intended size, which is the commonest reason published map figures are unreadable.


Multi-Panel Layouts

Four dates, one colour bar Four panels share one colour scale and one colour bar placed to the right of the grid. Each panel carries a small letter label in its corner and only the outer panels carry axis labels, which keeps the ink budget on the data rather than on repeated furniture. Share the scale, share the bar, label once a b c d shared NDVI scale axis labels on the outer panels only one bar, not four
import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 2, figsize=(7, 6), sharex=True, sharey=True,
                         constrained_layout=True)
for ax, (label, arr) in zip(axes.ravel(), panels.items()):
    im = ax.imshow(arr, extent=extent, cmap="YlGn", vmin=0.0, vmax=0.9,
                   interpolation="nearest")
    ax.set_title(label, fontsize=8, loc="left")
    ax.set_aspect("equal")

fig.colorbar(im, ax=axes, shrink=0.8, label="NDVI")   # one bar for the grid

sharex and sharey remove duplicate tick labels from the interior panels, and constrained_layout handles the spacing so the shared colour bar does not overlap anything. Passing ax=axes — the whole array rather than a single axes — is what makes one bar serve the grid.

Sharing vmin and vmax across the panels is not a convenience but a requirement: a grid of dates rendered on independent scales shows differences in stretch that readers will interpret as differences on the ground, which is the same failure described for split maps in comparing two dates with a split map.


Verification

from pathlib import Path

import matplotlib.pyplot as plt

for stem in ("figure3",):
    pdf, png = Path(f"{stem}.pdf"), Path(f"{stem}.png")
    assert pdf.exists() and png.exists()
    assert pdf.stat().st_size < 8_000_000, "PDF too large — is the array decimated?"
    print(stem, f"{pdf.stat().st_size/1e6:.2f} MB pdf,",
          f"{png.stat().st_size/1e6:.2f} MB png")
What is vector and what is raster inside the PDF Inside an exported PDF the image itself is stored as a raster at the decimated resolution, while the axes, tick labels, colour bar outline, scale bar and annotations remain vector objects. That combination keeps text crisp at any print size without embedding a full-resolution scene. One file, two kinds of content raster: the image, decimated vector: axes and ticks vector: colour bar outline vector: scale bar and labels Embedding the full-resolution scene turns a 2 MB figure into a 60 MB one for no visible gain.

The size bound is a proxy for the mistake that matters: a PDF of tens of megabytes almost always means the full-resolution array was embedded, which no journal will accept and no reader benefits from.

The other check is physical. Print the figure at its intended size, or view the PDF at 100%, and read the smallest label. If it is uncomfortable at arm’s length it will be illegible in a printed column, and the fix is a larger base font rather than a larger figure.


Common Errors

Text is tiny in the published version

The figure was created large and scaled down. Create it at the final size and set the font size explicitly.

The PDF is enormous

The full-resolution raster was embedded. Decimate the read to the pixel budget before plotting.

Fonts differ on another machine

pdf.fonttype was left at its default of 3, which references Type 3 fonts. Set it to 42 to embed TrueType.


Frequently Asked Questions

Q: What dpi should a map figure be exported at? 300 for print, 150 to 200 for slides and web. Exporting higher than the underlying array supports adds file size and no detail, so check the pixel budget first: figure width in inches times dpi is how many raster pixels the figure can actually show.

Q: PDF or PNG for a journal figure? PDF, because the axes, labels and colour bar stay as vectors while the image itself remains a raster. That gives crisp text at any print size without inflating the file, and most publishers prefer it.

Q: Why do my fonts change when the figure is opened elsewhere? The fonts were referenced rather than embedded. Set the PDF font type to 42 so TrueType fonts are embedded, and the figure renders identically on any machine.

Q: Should the figure include a north arrow? Only when the orientation is not obvious. A north-up projected raster with labelled eastings and northings already tells the reader which way is up, and an arrow adds furniture. Include one when the map is rotated, when it uses an unusual projection, or when the audience is not used to reading coordinates.