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
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.
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.
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.
Internal links
- How to build a print-ready map layout in Matplotlib โ drawing at final size
- How to export a map at print quality โ the export settings
- Accessible maps explained: contrast, text and alternatives โ the minimum size thresholds
- Fixing map labels that overlap or get clipped โ before reaching for a smaller font
- How to build a reusable map style module โ a constructor that takes millimetres
- Fixing missing or substituted fonts in an exported map PDF โ the other export failure
- How to add an inset and locator map in Python โ the element that fails first
- How to save a map image with matplotlib โ the basics
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.