Fixing Map Text That Is Too Small in the Exported File

Problem statement

The map looked fine in the notebook. In the document it is unreadable: the legend is a grey smear, the labels are illegible, and the source line has disappeared into a line of grey dots.

Nothing changed except the size. And that is exactly the problem โ€” the figure was drawn at a screen-comfortable size and then scaled to fit a column.

The arithmetic is unforgiving. A figure drawn 8 inches (203 mm) wide and placed in a 90 mm column is scaled by 0.443. Every point-based size in the figure is multiplied by that factor at once:

drawn      placed      verdict
 9 pt  โ†’   3.99 pt     illegible
 8 pt  โ†’   3.54 pt     illegible
 7 pt  โ†’   3.10 pt     illegible
 5.5 pt โ†’  2.44 pt     invisible

For reference, 8 pt is 2.82 mm of physical height and 6 pt โ€” the usual print minimum โ€” is 2.12 mm. There is no font size that survives being multiplied by 0.443.

Quick answer

Draw the figure at its final printed size and place it at 100%:

MM = 1 / 25.4

COLUMN_MM = 90                      # ask the journal, the template, the designer
fig, ax = plt.subplots(figsize=(COLUMN_MM * MM, COLUMN_MM * 0.8 * MM))

plt.rcParams.update({"font.size": 7})     # 7 pt is now genuinely 7 pt
fig.savefig("map.pdf")                    # insert at 100%, do not resize

Then check, rather than hope:

def check_text_sizes(fig, target_width_mm, min_pt=6.0):
    drawn_mm = fig.get_size_inches()[0] * 25.4
    scale = target_width_mm / drawn_mm
    print(f"drawn {drawn_mm:.0f} mm โ†’ placed {target_width_mm:.0f} mm  (ร—{scale:.3f})")
    problems = [(t.get_text()[:30], t.get_fontsize(), t.get_fontsize() * scale)
                for t in fig.findobj(plt.Text)
                if t.get_text().strip() and t.get_fontsize() * scale < min_pt]
    for label, drawn, placed in problems:
        print(f"  {label:32} {drawn:4.1f} pt โ†’ {placed:4.2f} pt")
    return problems
Four-step flow from asking for the column width to placing the figure at 100%.
The figure looks uncomfortably small while you work on it, and that is correct.

Step-by-step solution

1. Find the scale factor

Two numbers: the width the figure was drawn at, and the width it will occupy. Their ratio multiplies every type size, line width and marker size in the figure.

scale = target_width_mm / (fig.get_size_inches()[0] * 25.4)

A scale below about 0.9 means the figure will need redrawing rather than adjusting. Above 1.0 โ€” a small figure enlarged โ€” the text grows and the linework becomes chunky, which is a different failure with the same cause.

2. Redraw at the destination size

This is the fix. Everything else is mitigation.

fig, ax = plt.subplots(figsize=(90 / 25.4, 72 / 25.4))     # a 90 mm column

The figure is now small on screen, which feels wrong and is right. Zoom the notebook display rather than the figure โ€” the physical size is the contract with the document.

3. Set type sizes for print, not for screen

With the figure at final size, the point values mean what they say:

SIZES = {"title": 9, "label": 7, "legend": 6.5, "footer": 5.5}

The floor for body-adjacent text in print is about 6 pt; many publishers require 8 pt minimum in figures. 5.5 pt is acceptable for a provenance line that nobody has to read closely, and nothing below 5 pt should exist.

4. Do not use bbox_inches="tight" for a sized figure

It crops the saved file to the drawn content, so the exported file is no longer the size you set. Two figures cropped by different amounts then arrive at different scales in the same document, and their type sizes no longer match.

Use generous margins in GridSpec or subplots_adjust and export at the designed size.

5. If you cannot redraw, scale the type instead of the figure

Sometimes the figure is generated by code you do not control. The mitigation is to pre-divide every size by the scale factor:

def compensate(fig, scale):
    """Enlarge type and linework so they survive the reduction."""
    for text in fig.findobj(plt.Text):
        text.set_fontsize(text.get_fontsize() / scale)
    for ax in fig.axes:
        for line in ax.lines:
            line.set_linewidth(line.get_linewidth() / scale)
        for collection in ax.collections:
            collection.set_linewidth(
                [w / scale for w in collection.get_linewidth()])

This works and it is ugly: 7 pt divided by 0.443 is 15.8 pt, which looks enormous in the unscaled figure and correct after placement.

6. Check the exported file at 100%

Open the PDF, set the zoom to 100%, and read the smallest text at normal viewing distance. If the destination is paper, print the page.

Every failure in this guide is visible in five seconds at 100% zoom and invisible at the 150% most people work at.

Grid converting five point sizes to millimetres and pixels at 300 dpi.
Matplotlib maps points onto the physical figure size, so these are the real heights.

Code examples

Example 1 โ€” a size report for a whole figure

import matplotlib.pyplot as plt
import matplotlib.text as mtext


def size_report(fig, target_width_mm=None, min_pt=6.0, warn_pt=8.0):
    drawn_mm = fig.get_size_inches()[0] * 25.4
    scale = (target_width_mm / drawn_mm) if target_width_mm else 1.0

    print(f"figure  {drawn_mm:.0f} ร— {fig.get_size_inches()[1] * 25.4:.0f} mm")
    if target_width_mm:
        print(f"placed  {target_width_mm:.0f} mm  โ†’ scale {scale:.3f}")
        if scale < 0.95:
            print("  ! everything below is multiplied by this factor")

    rows = []
    for text in fig.findobj(mtext.Text):
        content = text.get_text().strip()
        if not content:
            continue
        drawn = text.get_fontsize()
        placed = drawn * scale
        status = ("illegible" if placed < min_pt
                  else "marginal" if placed < warn_pt else "ok")
        rows.append((content[:28], drawn, placed, placed * 25.4 / 72, status))

    rows.sort(key=lambda r: r[2])
    print(f"\n{'text':30} {'drawn':>6} {'placed':>7} {'mm':>5}  status")
    for content, drawn, placed, mm, status in rows[:12]:
        print(f"{content:30} {drawn:5.1f}  {placed:6.2f} {mm:5.2f}  {status}")

    bad = sum(1 for r in rows if r[4] == "illegible")
    print(f"\n{bad} of {len(rows)} text objects below {min_pt} pt after placement")
    return bad == 0

Example 2 โ€” a figure constructor that makes the mistake hard

MM = 1 / 25.4

WIDTHS_MM = {           # name the destinations rather than the numbers
    "column": 90, "wide": 140, "page": 170, "a4_landscape": 257,
    "slide_16_9": 254,
}


def figure_for(destination="column", aspect=0.75, **kwargs):
    """Create at final size. `destination` is where it will be placed."""
    width_mm = WIDTHS_MM[destination]
    fig, ax = plt.subplots(figsize=(width_mm * MM, width_mm * aspect * MM), **kwargs)
    fig.set_dpi(150)                 # screen preview only
    print(f"figure created at {width_mm} mm for '{destination}' โ€” place at 100%")
    return fig, ax

Naming the destination rather than passing a number is what stops the habit of "8 by 6 looks good on my screen".

Example 3 โ€” a regression test for a figure library

def assert_legible(fig, target_width_mm, min_pt=6.0):
    """Fail a build that would ship an unreadable figure."""
    scale = target_width_mm / (fig.get_size_inches()[0] * 25.4)
    offenders = [(t.get_text()[:24], round(t.get_fontsize() * scale, 2))
                 for t in fig.findobj(plt.Text)
                 if t.get_text().strip() and t.get_fontsize() * scale < min_pt]
    assert not offenders, (
        f"text below {min_pt} pt after placing at {target_width_mm} mm "
        f"(scale {scale:.3f}): {offenders}")

Put this in the test suite of any project that generates figures. It catches the failure at the moment it is introduced rather than at proof stage.

Explanation

Why point sizes are physical and everything follows from that

A point is 1/72 inch. When a figure is created with figsize in inches, matplotlib maps points onto that physical size directly, so 8 pt type is 8/72 inch โ€” 2.82 mm โ€” tall regardless of DPI.

Scaling the figure afterwards scales that physical size. There is no way for the type to resist, because it has no independent unit; it is defined relative to the figure.

Why the notebook hides it

Notebook figures are displayed at a comfortable pixel size regardless of their physical dimensions. A 90 mm figure and a 200 mm figure both fill a similar area on screen, so the type looks the same size in both โ€” and only the physical figure knows which is which.

That is why the on-screen impression is worthless for this decision, and why the check has to be arithmetic.

Why bbox_inches="tight" makes it worse

"tight" is convenient for a quick figure and destructive for a print layout. It crops to the drawn content, which means the exported width depends on how much white space happened to surround the map.

Two figures in the same report, cropped differently, are then placed at the same column width and end up at different scales โ€” so identical fontsize values produce different sizes on the page. The symptom is "the type sizes in my figures do not match" and the cause is the crop.

Why compensating is a last resort

Dividing every size by the scale factor works arithmetically and leaves a figure that is unusable for anything else: the on-screen version has enormous text, the line widths no longer relate to the type, and the next person to open it will "fix" it.

Redrawing at the destination size costs one changed line and removes the whole class of problem.

Two panels contrasting the notebook impression with the physical page result.
Notebook figures are displayed at a comfortable pixel size regardless of their dimensions.

Edge cases or notes

  • Journals often specify both a width and a minimum type size. Ask for both.
  • Slides need larger type, not smaller: 14โ€“18 pt at slide dimensions.
  • Line widths scale too. A 0.25 pt hairline at scale 0.443 is 0.11 pt and will not print.
  • Marker sizes are in points squared in scatter, so they scale as the square.
  • A figure enlarged past 1.0 gets chunky linework and oversized type โ€” the same bug in reverse.
  • fig.set_size_inches() after drawing re-lays out and can move labels placed in device space.
  • Check the smallest text, not the title. The provenance line fails first.
  • Different figures in one report must share a drawn width, or their type sizes will not match.

FAQ

Why is my map text tiny in the final document?

The figure was scaled on placement. An 8-inch figure in a 90 mm column scales by 0.443, so 8 pt type arrives at 3.54 pt.

What figure size should I use?

The physical size the figure will occupy. A 90 mm column means figsize=(90/25.4, ...), and the figure should then be placed at 100%.

What is the minimum readable type size on a printed map?

About 6 pt, which is 2.12 mm. Many publishers require 8 pt (2.82 mm) as a minimum inside figures.

Can I just increase the font size instead?

Only as a last resort, by dividing every size by the scale factor. The result looks absurd in the unscaled figure and breaks the next time anybody edits it.

Does bbox_inches="tight" cause this?

It contributes: it crops the export to the drawn content, so the file is no longer the size you set and two figures end up at different scales in the same document.

How do I catch this before publication?

Compute the scale factor and list every text object below your minimum. It takes eight lines and belongs in the test suite.