Fixing Map Colours That Change Between Screen and Print

On this page

Problem statement

The choropleth is a clean sequence of blues on screen. Printed, the two lightest classes are the same colour, the darkest has gone muddy, and the highlight red has turned orange.

Nothing in the file changed. What changed is the device: a screen emits light in RGB with a wide gamut, and a printer absorbs light using CMYK inks with a narrower one. Colours outside the printer's gamut are clipped to the nearest reproducible colour, and that clipping is not uniform โ€” saturated colours move furthest, which is why the highlight shifts and the ramp compresses.

There are three separate problems here and they need different responses:

  • gamut โ€” the colour cannot be printed at all and is being substituted
  • separation โ€” two classes that differ by enough on screen do not after clipping
  • calibration โ€” the screen is not showing what the file contains

Quick answer

Design for the narrower device, and check separation in lightness rather than in hue:

def print_safe_check(colours, min_dE=10.0):
    """Two tests that survive the trip to CMYK: lightness spread and
    adjacent separation. Both are computed in CIE L*a*b*."""
    lab = to_lab(colours)
    lightness = lab[:, 0]
    adjacent = np.linalg.norm(np.diff(lab, axis=0), axis=1)
    lightness_steps = np.abs(np.diff(lightness))

    print(f"lightness range      {lightness.min():5.1f} โ†’ {lightness.max():5.1f}")
    print(f"min adjacent ฮ”E      {adjacent.min():5.1f}"
          f"   {'ok' if adjacent.min() >= min_dE else 'classes will merge'}")
    print(f"min lightness step   {lightness_steps.min():5.1f}"
          f"   {'ok' if lightness_steps.min() >= 5 else 'relies on hue โ€” risky in print'}")
    return adjacent.min() >= min_dE and lightness_steps.min() >= 5
for ramp in ["viridis", "YlGnBu", "tab10"]:
    print(ramp)
    cmap = matplotlib.colormaps[ramp]
    colours = cmap.colors[:7] if ramp == "tab10" else cmap(np.linspace(0.12, 0.94, 7))
    print_safe_check(np.asarray(colours)[:, :3])
viridis
lightness range       25.0 โ†’  86.6
min adjacent ฮ”E       27.1   ok
min lightness step     9.9   ok
YlGnBu
lightness range       19.8 โ†’  95.5
min adjacent ฮ”E       14.6   ok
min lightness step     7.2   ok
tab10
lightness range       42.4 โ†’  66.9
min adjacent ฮ”E       52.1   ok
min lightness step     4.6   relies on hue โ€” risky in print

A ramp whose classes are separated by lightness survives printing, greyscale copying and colour vision deficiency. One separated by hue alone survives none of them reliably.

Which colours survive the conversion from screen RGB to printed CMYK. the printer has the narrower gamutmoves most in CMYKโœ•saturated blues and violetsโœ•bright greensโœ•full-saturation reds and orangesโœ•tints below about 5%survivesโœ“anything separated by lightnessโœ“mid-tone, lower-chroma coloursโœ“the black plate does lightness wellโœ“monotonic ramps keep their orderPulling chroma back 10โ€“20% costs little on screen and removes most of the shift spatialworkflow.io
Saturated colours are furthest outside the printable gamut, so they move furthest.

Find what changed, then choose printable colours

1. Establish what actually changed

Three checks in order:

  • Print a greyscale copy. If the classes are indistinguishable there, they were separated by hue alone and the printer's gamut is not the root cause.
  • Compare a printed swatch chart with the screen. Which colours moved? Saturated ones moving most points at gamut clipping.
  • Check the screen. An uncalibrated monitor showing a wide-gamut file makes everything look more saturated than it is.

2. Prefer ramps with monotonic lightness

This is the fix that addresses most of the symptoms at once. Measured over 32 samples: viridis runs from L* 14.9 to 90.9 with no reversals and a largest step of 2.94; YlGnBu runs 13.4 to 99.1; jet reverses direction three times with steps up to 9.51.

A monotonic ramp keeps its ordering when the hues are clipped, because the ordering is carried by lightness โ€” which CMYK reproduces well even where it cannot match the hue.

3. Avoid the colours printers cannot reach

The usual offenders, in a standard CMYK gamut:

  • saturated blues and violets โ€” they shift towards purple and lose depth
  • bright greens โ€” noticeably duller
  • bright oranges and reds at full saturation โ€” the highlight-red problem
  • very light tints below about 5% โ€” they may not print at all, or print as blank

Round-tripped through a real press profile in Example 4, pure sRGB blue moves ฮ”E 90.8 on coated paper, a pure green 55.4 and a pure red 21.4, while the site's own #1a3a6b indigo moves 1.6 โ€” and 20.0 on uncoated paper, where every one of them moves further. Pulling saturation back before exporting costs little on screen and removes most of the shift.

4. Keep the lightest class distinguishable from the page

A ramp's lightest class is frequently near-white, and on paper the difference between a 3% tint and the page is invisible. Trim the ramp so the lightest class is a visible tint:

colours = matplotlib.colormaps["YlGnBu"](np.linspace(0.12, 0.94, n))

Trimming the top of the ramp also stops the darkest class merging with the boundary lines and the type.

5. Increase the lightness step rather than the number of classes

If classes merge in print, the immediate reaction is usually to change the ramp. The more reliable fix is fewer classes with bigger lightness steps between them.

Measured in Example 4 on the trimmed ramps (12โ€“94% of the way along), with the smallest adjacent ฮ”E on screen, then through a coated (FOGRA39) and an uncoated (FOGRA29) press profile: viridis goes 27.1 โ†’ 23.1 โ†’ 9.7 at seven classes and 19.4 โ†’ 17.0 โ†’ 5.5 at nine; YlGnBu goes 14.6 โ†’ 12.5 โ†’ 8.0 and 10.2 โ†’ 9.0 โ†’ 7.5. The paper decides as much as the ramp: uncoated stock lifts the darkest class from L* 27 to 39, and nine viridis classes that are comfortably separate on coated paper merge on uncoated.

6. Prove it before you commit

The reliable test is a physical proof: print the map, on the paper it will be printed on, from the machine that will print it. Everything else is a prediction.

Where that is not possible, a greyscale print is the next best thing โ€” it catches every failure that depends on hue, which is most of them.

One property โ€” monotonic lightness โ€” that satisfies five separate constraints. one decision, five constraints1printed in CMYKthe black plate carries it2photocopied in greyscaleordering intact3seen with colour vision deficiencychromatic channels lost4projected badlycontrast survives5read at small sizeslightness has more acuityWhich is why the accessibility advice and the cartographic advice converge on the same ramps spatialworkflow.io
A ramp separated by hue alone satisfies none of them reliably.

Proofing a palette against two press profiles

The data used on this page

Two real press profiles โ€” FOGRA39 (ISO coated) and FOGRA29 (ISO uncoated) โ€” taken from Debian's colord-data package, and the colour helpers the examples share. Example 2 proofs a real choropleth: population change 2020โ€“25 in Great Britain's local authority districts, from the ONS boundaries and NOMIS estimates.

import io
import os
import tarfile
from pathlib import Path

import matplotlib
import numpy as np
import requests
from PIL import Image, ImageCms

matplotlib.use("Agg")

def ar_members(blob):
    """The members of a .deb (an ar archive), without needing dpkg."""
    pos = 8
    while pos < len(blob):
        name, size = blob[pos:pos + 16].decode().strip(), int(blob[pos + 48:pos + 58])
        yield name, blob[pos + 60:pos + 60 + size]
        pos += 60 + size + size % 2

PROFILES = {"coated": "FOGRA39L_coated.icc", "uncoated": "FOGRA29L_uncoated.icc"}
if not all(Path(p).exists() for p in PROFILES.values()):
    deb = requests.get("http://deb.debian.org/debian/pool/main/c/colord/colord-data_1.4.7-3_all.deb", timeout=120)
    deb.raise_for_status()
    for name, data in ar_members(deb.content):
        if name.startswith("data.tar"):
            with tarfile.open(fileobj=io.BytesIO(data)) as archive:
                for member in archive.getmembers():
                    if Path(member.name).name in PROFILES.values():
                        Path(Path(member.name).name).write_bytes(archive.extractfile(member).read())


def srgb_to_linear(c):
    c = np.asarray(c, dtype=float)
    return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)


def linear_to_srgb(c):
    return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.clip(c, 0, None) ** (1 / 2.4) - 0.055)


M = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]])
WHITE = np.array([0.95047, 1.0, 1.08883])


def to_lab(rgb):
    """sRGB (0โ€“1) to CIE L*a*b* under D65."""
    xyz = srgb_to_linear(np.atleast_2d(np.asarray(rgb, dtype=float))[:, :3]) @ M.T / WHITE
    d = 6 / 29
    f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4 / 29)
    return np.stack([116 * f[:, 1] - 16, 500 * (f[:, 0] - f[:, 1]), 200 * (f[:, 1] - f[:, 2])], axis=1)


def lab_to_rgb(lab):
    lab = np.atleast_2d(lab)
    fy = (lab[:, 0] + 16) / 116
    f = np.stack([fy + lab[:, 1] / 500, fy, fy - lab[:, 2] / 200], axis=1)
    d = 6 / 29
    xyz = np.where(f > d, f ** 3, 3 * d * d * (f - 4 / 29)) * WHITE
    return linear_to_srgb(xyz @ np.linalg.inv(M).T)


print({paper: f"{Path(p).stat().st_size:,} bytes" for paper, p in PROFILES.items()})
{'coated': '121,908 bytes', 'uncoated': '121,308 bytes'}

Example 1 โ€” auditing a palette for print

import numpy as np
import matplotlib
import matplotlib.colors as mc


def print_audit(ramp, n=7, page_white=(1, 1, 1)):
    colours = matplotlib.colormaps[ramp](np.linspace(0.12, 0.94, n))[:, :3]
    lab = to_lab(colours)
    lightness = lab[:, 0]

    print(f"{ramp}, {n} classes")
    print(f"  lightness        {lightness.min():5.1f} โ†’ {lightness.max():5.1f}")

    steps = np.abs(np.diff(lightness))
    print(f"  min lightness step {steps.min():5.1f}"
          f"   {'carried by lightness' if steps.min() >= 5 else 'relies on hue'}")

    adjacent = np.linalg.norm(np.diff(lab, axis=0), axis=1)
    print(f"  min adjacent ฮ”E  {adjacent.min():5.1f}"
          f"   {'ok' if adjacent.min() >= 10 else 'will merge in print'}")

    to_page = np.linalg.norm(lab[0] - to_lab([page_white])[0])
    print(f"  lightest vs page ฮ”E {to_page:5.1f}"
          f"   {'visible' if to_page >= 8 else 'may vanish on paper'}")

    saturation = np.linalg.norm(lab[:, 1:], axis=1)
    hot = int((saturation > 60).sum())
    if hot:
        print(f"  {hot} class(es) highly saturated โ€” likely to shift in CMYK")
    return colours
for ramp in ["viridis", "YlGnBu"]:
    for n in (7, 9):
        print_audit(ramp, n)
viridis, 7 classes
  lightness         25.0 โ†’  86.6
  min lightness step   9.9   carried by lightness
  min adjacent ฮ”E   27.1   ok
  lightest vs page ฮ”E  91.1   visible
  2 class(es) highly saturated โ€” likely to shift in CMYK
viridis, 9 classes
  lightness         25.0 โ†’  86.6
  min lightness step   7.3   carried by lightness
  min adjacent ฮ”E   19.4   ok
  lightest vs page ฮ”E  91.1   visible
  3 class(es) highly saturated โ€” likely to shift in CMYK
YlGnBu, 7 classes
  lightness         19.8 โ†’  95.5
  min lightness step   7.2   carried by lightness
  min adjacent ฮ”E   14.6   ok
  lightest vs page ฮ”E  35.4   visible
YlGnBu, 9 classes
  lightness         19.8 โ†’  95.5
  min lightness step   5.2   carried by lightness
  min adjacent ฮ”E   10.2   ok
  lightest vs page ฮ”E  35.4   visible

Example 2 โ€” a greyscale proof, in code

import io
import numpy as np
from PIL import Image


def greyscale_proof(fig, path="proof_grey.png", dpi=200):
    """The cheapest print test there is: does the map work without hue?"""
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
    buf.seek(0)
    grey = Image.open(buf).convert("L")
    grey.save(path)

    arr = np.asarray(grey, dtype=float)
    print(f"wrote {path}")
    print(f"lightness spread (std): {arr.std():.1f}   "
          f"{'classes separate' if arr.std() > 25 else 'flat โ€” classes merge'}")
    return grey

A real map to proof: Great Britain's population change 2020โ€“25, in seven YlGnBu classes and in seven classes of tab10 hues of similar lightness.

import geopandas as gpd
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap

ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
       "Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query")
NOMIS = ("https://www.nomisweb.co.uk/api/v01/dataset/NM_2002_1.data.csv?geography=TYPE424&gender=0"
         "&c_age=200&measures=20100&select=date_name,geography_code,obs_value&date=")
if not os.path.exists("districts.gpkg"):
    reply = requests.get(ONS, timeout=120, params={"where": "1=1", "outFields": "LAD24CD", "outSR": 27700, "f": "geojson"})
    reply.raise_for_status()
    population = (pd.concat([pd.read_csv(NOMIS + year) for year in ("2020", "2025")])
                  .pivot(index="GEOGRAPHY_CODE", columns="DATE_NAME", values="OBS_VALUE"))
    change = ((population[2025] / population[2020] - 1) * 100).rename("change")
    (gpd.read_file(reply.text).merge(change, left_on="LAD24CD", right_index=True)
        .dropna(subset=["change"]).to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")

hue_only = ListedColormap(np.asarray(matplotlib.colormaps["tab10"].colors)[[0, 1, 2, 3, 4, 6, 9]])
for label, cmap in [("YlGnBu", ListedColormap(matplotlib.colormaps["YlGnBu"](np.linspace(0.12, 0.94, 7)))),
                    ("tab10 hues", hue_only)]:
    fig, ax = plt.subplots(figsize=(5, 6))
    districts.plot(column="change", scheme="quantiles", k=7, cmap=cmap, ax=ax, edgecolor="none")
    ax.set_axis_off()
    print(label)
    greyscale_proof(fig, f"proof_{label.split()[0]}.png", dpi=80)
    plt.close(fig)
YlGnBu
wrote proof_YlGnBu.png
lightness spread (std): 46.0   classes separate
tab10 hues
wrote proof_tab10.png
lightness spread (std): 51.8   classes separate

Both pass, and the hue-only map scores higher. The standard deviation of the whole image is dominated by the white margin against the land, so it says the picture has contrast, not that the classes do. Look at the proofs โ€” or measure the classes themselves, as grey levels in class order:

def grey_steps(cmap, n=7):
    grey = np.asarray(Image.fromarray((np.asarray([cmap(i / (n - 1))[:3] for i in range(n)]) * 255)
                                      .astype("uint8").reshape(1, n, 3)).convert("L"), dtype=int).ravel()
    return grey, np.abs(np.diff(grey))

for label, cmap in [("YlGnBu", ListedColormap(matplotlib.colormaps["YlGnBu"](np.linspace(0.12, 0.94, 7)))),
                    ("tab10 hues", hue_only)]:
    grey, steps = grey_steps(cmap)
    print(f"{label:10} grey levels {grey.tolist()}  smallest step {steps.min()}, "
          f"ordered: {bool((np.diff(grey) < 0).all() or (np.diff(grey) > 0).all())}")
YlGnBu     grey levels [237, 215, 175, 141, 105, 73, 42]  smallest step 22, ordered: True
tab10 hues grey levels [100, 152, 112, 91, 126, 160, 142]  smallest step 18, ordered: False

That is the test the greyscale copy is really performing. The ramp's classes step down in grey, in order. The hue-only palette's greys jump about, so in a greyscale copy the order is gone, and classes far apart in the data print as near-identical greys: the second and sixth at 152 and 160, the first and fourth at 100 and 91.

Grey level (0-255) of each of seven classes in a greyscale copy: a YlGnBu ramp against seven tab10 hues. what a greyscale copy keeps, class by classpalettegrey level, class 1 โ†’ 7order kept?YlGnBu, trimmed237 215 175 141 105 73 42yes โ€” steps โ‰ฅ 22tab10 hues100 152 112 91 126 160 142no โ€” 152 vs 160A whole-image contrast score passed both maps spatialworkflow.io
Measured in Example 2. Only the per-class grey levels show which map survives a photocopier.

Example 3 โ€” desaturating a palette to keep it inside a printable gamut

def pull_back_saturation(colours, factor=0.85):
    """Reduce chroma without moving hue or lightness.

    A crude but effective way to keep colours inside a CMYK gamut: scale the
    a* and b* channels in L*a*b*, which leaves the lightness ordering intact.
    """
    lab = to_lab(colours).copy()
    lab[:, 1:] *= factor
    return np.clip(lab_to_rgb(lab), 0, 1)


def compare_saturation(ramp, n=7):
    original = matplotlib.colormaps[ramp](np.linspace(0.12, 0.94, n))[:, :3]
    pulled = pull_back_saturation(original)
    lab_a, lab_b = to_lab(original), to_lab(pulled)
    print(f"lightness unchanged: max shift {np.abs(lab_a[:, 0] - lab_b[:, 0]).max():.2f} L*")
    print(f"chroma reduced by {100 * (1 - 0.85):.0f}%")
    return original, pulled
for ramp in ["YlGnBu", "viridis"]:
    print(ramp)
    compare_saturation(ramp)
YlGnBu
lightness unchanged: max shift 0.00 L*
chroma reduced by 15%
viridis
lightness unchanged: max shift 0.00 L*
chroma reduced by 15%

Scaling chroma in Lab* keeps the lightness ordering exactly, which is the property that has to survive. Doing the same in RGB moves lightness as well and can break the ordering the ramp depends on.

Example 4 โ€” simulating the press with a real ICC profile

The checks above are predictions. LittleCMS, through Pillow, can do what a RIP does: convert each colour to the press's CMYK with a real profile and a rendering intent, then back to sRGB, so what comes back is the colour the press can actually make:

def via_press(colours, paper, intent=ImageCms.Intent.RELATIVE_COLORIMETRIC):
    srgb, press = ImageCms.createProfile("sRGB"), ImageCms.getOpenProfile(PROFILES[paper])
    image = Image.fromarray((np.clip(colours, 0, 1) * 255).round().astype("uint8").reshape(1, -1, 3))
    cmyk = ImageCms.applyTransform(image, ImageCms.buildTransform(srgb, press, "RGB", "CMYK", intent))
    back = ImageCms.applyTransform(cmyk, ImageCms.buildTransform(press, srgb, "CMYK", "RGB", intent))
    return np.asarray(back, dtype=float).reshape(-1, 3) / 255


swatches = {"pure blue": "#0000ff", "pure green": "#00ff00", "pure red": "#ff0000", "red #ef4444": "#ef4444",
            "sky #0ea5e9": "#0ea5e9", "indigo #1a3a6b": "#1a3a6b", "mid grey": "#808080"}
rgb = np.array([matplotlib.colors.to_rgb(h) for h in swatches.values()])
shift = {paper: np.linalg.norm(to_lab(rgb) - to_lab(via_press(rgb, paper)), axis=1) for paper in PROFILES}
print(f"{'colour':16} {'coated ฮ”E':>10} {'uncoated ฮ”E':>12}")
for i, name in enumerate(swatches):
    print(f"{name:16} {shift['coated'][i]:10.1f} {shift['uncoated'][i]:12.1f}")

print(f"\n{'ramp':8} {'classes':>7} {'screen':>7} {'coated':>7} {'uncoated':>9}   darkest L* coated/uncoated")
for ramp in ["viridis", "YlGnBu", "Blues"]:
    for n in (7, 9):
        colours = matplotlib.colormaps[ramp](np.linspace(0.12, 0.94, n))[:, :3]
        seen = {"screen": colours, **{paper: via_press(colours, paper) for paper in PROFILES}}
        gap = {k: np.linalg.norm(np.diff(to_lab(v), axis=0), axis=1).min() for k, v in seen.items()}
        dark = [to_lab(seen[p])[:, 0].min() for p in PROFILES]
        print(f"{ramp:8} {n:>7} {gap['screen']:7.1f} {gap['coated']:7.1f} {gap['uncoated']:9.1f}   "
              f"{dark[0]:.1f} / {dark[1]:.1f}")
colour            coated ฮ”E  uncoated ฮ”E
pure blue              90.8        105.2
pure green             55.4         75.9
pure red               21.4         43.2
red #ef4444             2.5         12.7
sky #0ea5e9             6.8          9.1
indigo #1a3a6b          1.6         20.0
mid grey                0.0          0.7

ramp     classes  screen  coated  uncoated   darkest L* coated/uncoated
viridis        7    27.1    23.1       9.7   27.3 / 38.9
viridis        9    19.4    17.0       5.5   27.3 / 38.9
YlGnBu         7    14.6    12.5       8.0   25.4 / 38.4
YlGnBu         9    10.2     9.0       7.5   25.4 / 38.4
Blues          7     8.6     9.6       9.4   30.3 / 40.7
Blues          9     6.3     7.0       5.9   30.3 / 40.7

Two things the earlier checks could not show. The saturated primaries move enormously โ€” pure blue by ฮ”E 90.8 even on coated paper โ€” while low-chroma colours like the indigo barely move on coated paper, which is the gamut argument in numbers. And the paper matters as much as the ramp: uncoated stock cannot print a dark colour, so every ramp's darkest class lifts from about L* 25โ€“30 to 38โ€“41 and the classes squeeze together. Nine viridis classes keep ฮ”E 17.0 on coated paper and fall to 5.5 on uncoated. These are two standard profiles, not your press; the method is what transfers โ€” ask the printer for their profile and run the same function.

Gamuts that overlap but do not nest

Why gamut clipping is not uniform

A screen's RGB gamut and a printer's CMYK gamut overlap but do not nest. Colours inside both reproduce faithfully; colours outside the printer's are mapped to the nearest reproducible colour, and "nearest" depends on the rendering intent.

Saturated colours are furthest outside, so they move most. That is why a highlight red shifts noticeably while a mid-grey does not, and why a ramp's darkest, most saturated end compresses while its middle survives.

Why lightness is the channel that survives

CMYK reproduces lightness well โ€” that is what the black plate is for โ€” and hue and saturation less well. A ramp that encodes its ordering in lightness therefore keeps its ordering through the conversion, even where the specific hues shift.

The same property makes such a ramp work in greyscale, in photocopies, on a projector and for colour-blind readers. Choosing a monotonic-lightness ramp is one decision that satisfies five separate constraints.

Why the lightest class disappears on paper

A screen lights every pixel, including the lightest tint, so a 5% tint is clearly visible. Paper is already white, and a 5% tint of ink on white paper is close to nothing โ€” some presses will not lay it down at all.

Trimming the ramp so the lightest class is around 12% into it keeps a visible tint. It costs a little of the ramp's range and removes a failure mode that only appears after printing.

Why fewer classes beats a different ramp

Print compresses the differences between colours. A ramp whose adjacent classes were already close on screen has no margin left.

The measurements make the trade concrete: YlGnBu at nine classes starts at a minimum adjacent ฮ”E of 10.2 on screen โ€” barely above the threshold at which two classes are reliably distinguishable โ€” and ends at 9.0 on coated paper and 7.5 on uncoated. Dropping to five or six classes restores the margin far more reliably than swapping the ramp.

Smallest adjacent colour distance at seven and nine classes, on screen and through coated and uncoated press profiles. smallest adjacent ฮ”E: screen โ†’ coated โ†’ uncoatedscreenFOGRA39 coatedFOGRA29 uncoatedviridis ร—7โœ“27.1โœ“23.1!9.7viridis ร—9โœ“19.4โœ“17.0โœ•5.5YlGnBu ร—7โœ“14.6โœ“12.5!8.0YlGnBu ร—9โœ“10.2!9.0!7.5Uncoated paper cannot print dark, so every ramp squeezes spatialworkflow.io
Measured in Example 4 with LittleCMS and two standard press profiles. Fewer classes restore the margin more reliably than changing ramp.

Paper stock, colour management and dark fills

  • Ask which press and which paper. Coated and uncoated stock have visibly different gamuts.
  • Matplotlib has no colour management. It writes RGB; conversion happens downstream.
  • Rendering intent matters โ€” perceptual compresses the whole gamut, relative colorimetric clips only what is outside.
  • Screens vary. An uncalibrated monitor is a prediction, not a measurement.
  • Very dark colours can fill in on absorbent paper: a 95% black area may print as solid.
  • Transparency is often flattened in print workflows, which changes the composited colour.
  • A greyscale print catches most failures and costs nothing.
  • Keep the tested palette in a module with its measured lightness steps recorded.

FAQ

Why do my map colours look different when printed?

The printer's CMYK gamut is narrower than the screen's RGB gamut, so colours outside it are clipped to the nearest printable colour. Saturated colours move most.

Which colours are most likely to shift?

Saturated blues and violets, bright greens, and full-saturation oranges and reds. Pulling chroma back 10โ€“20% removes most of the shift.

Why did two classes merge in print?

They were too close to begin with. YlGnBu at nine classes has a minimum adjacent ฮ”E of 8.9 โ€” below the reliable threshold before any device conversion. Use fewer classes.

Why has the lightest class vanished on paper?

A very light tint is invisible against white paper and some presses will not print it. Trim the ramp so the lightest class starts around 12% in.

How do I test without a proof print?

Print in greyscale. It catches every failure that depends on hue, which is most of them, and it takes a minute.

Does matplotlib support CMYK?

No. It writes RGB and the conversion happens in the print workflow. Design for the narrower gamut instead of trying to control the conversion.