How-to
Automating Reporting WorkflowsDeep dive

Embed a Chart Image in the Body of a Report Email

Show the report chart right in the email instead of hiding it in an attachment: render the workbook's own Excel chart to a sharp PNG through LibreOffice and pypdfium2 (or draw it with matplotlib), size it for phones and high-DPI screens, keep it light and dark-mode safe, embed several images by Content-ID, and test that every image reference resolves.

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

Bash
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:

Python
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.

From workbook chart to email image LibreOffice converts the chart sheet to PDF, pypdfium2 renders the page at twice the display resolution, Pillow crops the white margins, and the PNG is attached inline and referenced by Content-ID from the HTML. Chart sheet the workbook's own chart PDF soffice --convert-to pdf PNG at 2x pypdfium2 + crop Inline part cid: reference

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:

Python
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:

Python
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.

Three ways to put an image in an email An inline part referenced by Content-ID is shown by almost every client without a download prompt. A remote image URL is blocked by default in many clients and lets the sender track opens. A data URI inside the HTML is ignored or stripped by several major clients. inline part, cid: shown by nearly all clients no external request adds to message size remote URL blocked until allowed needs a public host small message data: URI ignored by several clients bloats the HTML part may be flagged as spam

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:

Python
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:

Python
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.

Where the chart lives inside the message The message is multipart mixed. Inside it, a multipart alternative holds the plain text and a multipart related part, which contains the HTML and the two inline PNG images. The workbook is a separate attachment at the top level. multipart/mixed the whole message multipart/alternative text/plain · or · HTML multipart/related text/html + image/png × 2 (cid) attachment report.xlsx

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 width attribute. 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.

More on email delivery

More on email delivery →