Embed a Chart Image in the Body of a Report Email
Most recipients of a report email read the body on a phone and never open the attached workbook. If the one chart that matters is in the body, they see it; if it is only in the attachment, most of them do not. Getting a chart into the body takes two things: an image of the chart that looks sharp on a high-resolution phone screen, and an email that carries it as an inline part referenced by Content-ID, because most mail clients block remote images and many ignore data: URIs. This page focuses on the image — including how to render the chart from the workbook itself, so the email shows exactly what the attachment contains — then embeds one or several images and tests that every reference resolves. The overall message structure (plain text, HTML, attachments) is covered in Email an Excel Report with an HTML Summary Body. It belongs to Emailing Excel Reports with smtplib.
Prerequisites
pip install pypdfium2 pillow matplotlib
report.xlsx is the report being sent: a Data sheet with revenue by region and a Chart sheet holding a column chart of it. LibreOffice (soffice) renders the workbook for the first method; pypdfium2 turns the PDF page into pixels and Pillow crops it. The second method needs only matplotlib.
Step 1 — Render the workbook's own chart to PNG
If the email shows a chart drawn separately with matplotlib, it can drift from the chart in the attached workbook — a different colour, a different sort order, a different number. Rendering the workbook's chart avoids that. Convert the chart sheet to PDF with LibreOffice, render the page at a high resolution, and crop away the white margins:
import subprocess
import pypdfium2 as pdfium
from PIL import Image, ImageChops
subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "report.xlsx"], check=True, capture_output=True)
def render_chart(pdf_path, page=0, scale=2.5, margin=10):
pdf = pdfium.PdfDocument(pdf_path)
img = pdf[page].render(scale=scale).to_pil().convert("RGB")
pdf.close()
bbox = ImageChops.difference(img, Image.new("RGB", img.size, "white")).getbbox()
left, top, right, bottom = bbox
return img.crop((left - margin, top - margin, right + margin, bottom + margin))
chart = render_chart("report.pdf")
chart.save("chart.png", optimize=True)
print("chart.png", chart.size)
LibreOffice prints the Chart sheet first because it is the first sheet in the workbook; the chart is the only thing on it, so cropping to the non-white area leaves exactly the chart. PDF pages are measured in points, 72 to the inch; scale=2.5 renders at 180 dots per inch, so the chart — 640 pixels wide in the workbook, which is 480 points on the page — comes out about 1,200 pixels wide, twice the 600-pixel width it will be displayed at. That doubling is what keeps text crisp on high-density phone and laptop screens. If the chart sits on a sheet with a table, give it a sheet of its own for rendering, or convert just that sheet as shown in Convert Only Selected Sheets of a Workbook to PDF.
Step 2 — Or draw it with matplotlib
When there is no workbook chart — or you want a chart designed for the email, such as the quarter's trend, which the workbook does not chart — matplotlib produces one directly. Set the size in inches and the DPI so the pixel width is twice the display width:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
months, revenue = ["Jul", "Aug", "Sep"], [1290, 1356, 1437]
fig, ax = plt.subplots(figsize=(6, 3.2), dpi=200) # 1200 x 640 px, shown at 600 x 320
ax.plot(months, revenue, color="#4338CA", marker="o", linewidth=2.5)
ax.set_title("Total revenue by month (k€)")
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
fig.savefig("chart_mpl.png", facecolor="white")
plt.close(fig)
with Image.open("chart_mpl.png") as im:
print("chart_mpl.png", im.size)
facecolor="white" matters: a transparent background looks fine in a light mail client but turns into dark text on a dark background in mail apps' dark mode, where the axis labels can vanish. A solid white background keeps the chart legible in both. matplotlib.use("Agg") lets the script run on a server with no display. More on matplotlib output for reports is in Embed a Matplotlib Chart in an Excel Report.
Step 3 — Size the image for mail clients
Mail clients do not agree on CSS, but nearly all honour the width and height attributes of an img tag. Set them to the display size — half the pixel size of a 2x image — and add max-width:100% so the image shrinks on narrow screens:
from pathlib import Path
def img_tag(path, src, alt, display_width=600):
with Image.open(path) as im:
w, h = im.size
display_height = round(h * display_width / w)
return (f'<img src="{src}" alt="{alt}" width="{display_width}" height="{display_height}" '
f'style="display:block;max-width:100%;height:auto;border:0">')
size_kb = Path("chart.png").stat().st_size / 1024
print(img_tag("chart.png", "cid:revenue", "Revenue by region: North 410k, South 380k, East 295k, West 352k"))
print(f"chart.png is {size_kb:.0f} KB")
A 600-pixel display width matches the width of most email layouts. The alt text matters more than usual here: clients that block images until the reader allows them show the alt text in its place, so put the conclusion in it — the actual figures — rather than "chart". Keep each image well under 200 KB; flat charts compress very well as PNG (the rendered chart here is a few tens of kilobytes), and a heavy body is slow to load on mobile data.
Step 4 — Embed several images by Content-ID
A report body often has two or three charts. A small helper takes HTML containing {cid:name} placeholders and a dictionary of image files, generates a unique Content-ID for each, and attaches each image to the HTML part:
from email.message import EmailMessage
from email.utils import make_msgid
def build_email(subject, text, html_template, images, attachments=()):
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = "reports@example.com"
msg["To"] = "team@example.com"
msg.set_content(text)
cids = {name: make_msgid(domain="reports.example.com") for name in images}
html = html_template
for name, cid in cids.items():
html = html.replace(f"{{cid:{name}}}", "cid:" + cid[1:-1])
msg.add_alternative(html, subtype="html")
html_part = msg.get_payload()[1]
for name, path in images.items():
html_part.add_related(Path(path).read_bytes(), maintype="image", subtype="png",
cid=cids[name], filename=Path(path).name, disposition="inline")
for path in attachments:
msg.add_attachment(Path(path).read_bytes(), maintype="application",
subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=Path(path).name)
return msg
html = f"""<p>September revenue was <b>1,437k€</b>, up 6% on August.</p>
{img_tag("chart.png", "{cid:revenue}", "Revenue by region: North 410k, South 380k, East 295k, West 352k")}
<p>Trend over the quarter:</p>
{img_tag("chart_mpl.png", "{cid:trend}", "Total revenue: July 1,290k, August 1,356k, September 1,437k")}"""
msg = build_email("Revenue report — September 2026",
"September revenue was 1,437k€, up 6% on August. Charts are in the HTML version; the workbook is attached.",
html, {"revenue": "chart.png", "trend": "chart_mpl.png"}, attachments=["report.xlsx"])
Path("report_email.eml").write_bytes(msg.as_bytes())
print([p.get_content_type() for p in msg.walk()])
make_msgid() returns an identifier in angle brackets for the Content-ID header; the src attribute uses the same identifier without them, as cid:.... Each image is attached to the HTML part with add_related, which turns that part into a multipart/related container — the place where cid: references are looked up. The workbook is added to the message itself, as an ordinary attachment. Saving the message as an .eml file lets you open it in a desktop mail client to see it exactly as recipients will before sending anything.
Step 5 — Test that every image reference resolves
The classic failure is an image that shows as a broken icon because its cid: does not match any part. Parse the finished message and compare:
import re
from email import policy
from email.parser import BytesParser
parsed = BytesParser(policy=policy.default).parsebytes(Path("report_email.eml").read_bytes())
html_body = parsed.get_body(preferencelist=("html",)).get_content()
referenced = set(re.findall(r'src="cid:([^"]+)"', html_body))
available = {p["Content-ID"].strip("<>") for p in parsed.walk() if p["Content-ID"]}
print("referenced:", len(referenced), "| available:", len(available))
print("unresolved:", referenced - available or "none", "| unused parts:", available - referenced or "none")
print("message size:", round(len(Path("report_email.eml").read_bytes()) / 1024), "KB")
Both images are referenced and both are present, with no unused image parts — an unused inline part shows up in some clients as a stray attachment. The size line is the one to watch as charts are added: base64 encoding inflates every image by about a third, and messages over a few megabytes start to be rejected. Run this check in the tests of the report job; for an end-to-end check that also exercises sending, use Test Report Emails with a Local SMTP Server.
Common pitfalls
- Images at 1x. They look blurry on phones; render at twice the display width.
- Transparent backgrounds. Dark mode turns them unreadable; use white.
- No
widthattribute. Some clients show a 1200-pixel image at full size. - Mismatched Content-IDs. Test that every
cid:resolves. - Generic alt text. Put the key figures in it for clients that block images.
Conclusion
To show a report chart in the email body, render it to a PNG at twice its display size — from the workbook's own chart via LibreOffice, pypdfium2 and a crop, or drawn with matplotlib on a white background — then embed it as an inline part related to the HTML and reference it with cid:. Give the img tag width, height and meaningful alt text, keep images small, and parse the finished message in a test to confirm every reference resolves.
Frequently asked questions
How do I put an image in the body of an email with Python? Create an EmailMessage, add the HTML with add_alternative, then call add_related on the HTML part with the image bytes and a Content-ID from make_msgid. Reference it in the HTML as src="cid:" followed by the ID without angle brackets.
How do I turn an Excel chart into an image? Convert the workbook or the chart's sheet to PDF with LibreOffice, render the page with pypdfium2 at a scale of about 2.5, and crop the white margins with Pillow.
Why is my email chart blurry on a phone? The image was rendered at the size it is displayed. Render it at twice the width and set the img width attribute to the display width.
Why does my embedded image show as a broken icon? The cid in the HTML does not match the Content-ID of any image part, or the image was attached to the message instead of the HTML part. Parse the message and compare the two sets.
Related
- Up one level: Emailing Excel Reports with smtplib — sending reports by email.
- Email an Excel Report with an HTML Summary Body — the full message structure.
- Zip Large Excel Attachments Before Emailing — keeping messages under size limits.
- Send an Excel Report with the SendGrid API — the same email through an API.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toDownload Excel Attachments from an Email Inbox with Python
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toEmail an Excel Report with an HTML Summary Body
- How-toPersonalise Report Emails with Jinja2 Templates