Render an Excel Range as an Image with Python
A weekly report often has one block that people actually read: five KPIs, this week against last, the change in green or red. Attaching the whole workbook to a chat message or an email makes everyone open it to see those five lines. Putting a picture of the block in the message body — exactly as it looks in Excel, number formats and colours included — gets it read. Excel can "Copy as Picture" by hand; this page shows how to do the same from a script, both on a Linux server without Excel and on a Windows machine with it. It is part of Inserting Images and Logos into Excel.
Prerequisites
pip install openpyxl pillow
# Linux: LibreOffice and poppler for the rendering
sudo apt install libreoffice-calc poppler-utils
weekly_kpis.xlsx has a Data sheet and a Summary sheet. The part worth sharing is Summary!A3:D7: a header row in brand colours, four KPIs with thousands separators, and a change column that shows falls in red. Below it are fifty lines of detail that must not appear in the picture.
Step 1 — Limit the output to the range
Everything that turns a workbook into pixels without Excel works through printing: LibreOffice renders what would be printed. So the first step is to make the printed output contain only the range. Set a print area, fit it to one page, and hide every other sheet — LibreOffice's PDF export includes every visible sheet, so without that the Data sheet would come first:
from openpyxl import load_workbook
def prepare(xlsx, sheet, ref, out):
wb = load_workbook(xlsx)
target = wb[sheet]
for ws in wb.worksheets:
ws.sheet_state = "visible" if ws is target else "hidden"
wb.active = wb.worksheets.index(target)
target.print_area = ref
target.sheet_properties.pageSetUpPr.fitToPage = True
target.page_setup.fitToWidth = 1
target.page_setup.fitToHeight = 0
target.print_options.gridLines = False
wb.save(out)
prepare("weekly_kpis.xlsx", "Summary", "A3:D7", "to_render.xlsx")
print(load_workbook("to_render.xlsx")["Summary"].print_area)
The target sheet is made active before the others are hidden, so the copy never has a hidden sheet as its active one — openpyxl saves that state without complaint, but Excel itself never produces it, and programs opening such a file handle it inconsistently. fitToWidth = 1 with fitToHeight = 0 scales a wide range down to one page width without forcing it onto one page vertically. The work is done on a copy, so the real report keeps its own print settings — Set the Print Area and Page Setup with openpyxl covers those options in full.
Step 2 — Convert to PDF and rasterise
Headless LibreOffice converts the prepared copy to PDF, and pdftoppm from poppler turns the page into a PNG at whatever resolution you ask for:
import subprocess
import tempfile
from pathlib import Path
def to_png(xlsx, png, dpi=200):
with tempfile.TemporaryDirectory() as tmp:
subprocess.run(["soffice", f"-env:UserInstallation=file://{tmp}/profile", "--headless",
"--convert-to", "pdf", "--outdir", tmp, xlsx],
check=True, capture_output=True, timeout=120)
pdf = Path(tmp) / (Path(xlsx).stem + ".pdf")
subprocess.run(["pdftoppm", "-png", "-r", str(dpi), "-singlefile", str(pdf), str(Path(png).with_suffix(""))],
check=True)
return png
to_png("to_render.xlsx", "page.png")
from PIL import Image
print(Image.open("page.png").size)
The -env:UserInstallation argument gives each conversion its own LibreOffice profile in the temporary directory. Without it, two conversions running at the same time — or a conversion while someone has LibreOffice open on the same account — share one profile, and the second can fail or exit without writing a file — which check=True alone does not catch, so check that the PDF exists before going on. -singlefile writes just the first page without a page-number suffix. At 200 dpi a US Letter page is 1700 × 2200 pixels; the range occupies only the top-left corner of it.
Step 3 — Crop to the content
Compare the page with a plain white image of the same size; getbbox() on the difference returns the rectangle that contains anything that is not white. Crop to it with a little padding:
from PIL import ImageChops
def crop_white(png, out, pad=12):
img = Image.open(png).convert("RGB")
diff = ImageChops.difference(img, Image.new("RGB", img.size, "white"))
left, top, right, bottom = diff.getbbox()
img.crop((max(left - pad, 0), max(top - pad, 0),
min(right + pad, img.width), min(bottom + pad, img.height))).save(out)
return Image.open(out).size
print(crop_white("page.png", "weekly_kpis.png"))
The result is a tight PNG of the five rows: header in white on indigo, numbers right-aligned with separators, and the Returns change in red. The resolution is set by dpi — 200 is sharp in an email on a high-density screen; 300 suits slides.
Step 4 — Use the picture
The PNG can go anywhere a picture can: inline in an HTML email with a cid: reference, attached to a Slack or Teams message, placed on a slide, or inserted into another workbook as a snapshot. For email, Email an Excel Report with an HTML Summary Body shows how to embed an image so it displays inline rather than as an attachment; for chat, see Post an Excel Report to a Slack Channel. Always send the workbook alongside the image when people might need the detail — the picture is for reading, not for working.
On Windows with Excel: xlwings
When Excel is installed, it can render the range itself, which is the most faithful result — the fonts, conditional formats and sparklines are exactly what the reader would see in Excel. xlwings exposes this as Range.to_png:
import xlwings as xw
with xw.App(visible=False) as app:
book = app.books.open(r"C:\reports\weekly_kpis.xlsx")
book.sheets["Summary"].range("A3:D7").to_png(r"C:\reports\weekly_kpis.png")
book.close()
Behind the scenes Excel copies the range as a picture and pastes it into a temporary chart, which is exported. That uses the clipboard, so avoid running it while someone is working in an interactive session on the same machine, and it needs a desktop session — see Run Excel COM Automation from Task Scheduler for scheduled runs.
When a pure-Python drawing is enough
If the table is plain — no conditional formats, no merged headers — you can skip the office suite and draw it with matplotlib's table, formatting the numbers yourself. It is quick and has no system dependencies, but every format, colour and width is re-implemented in Python, so it drifts from what the workbook shows. Use it for simple tables in environments where installing LibreOffice is not an option, and use a real renderer when the picture must match the workbook.
Common pitfalls
- Serif fonts in the output. A cell font with no name set falls back to LibreOffice's default, often a serif face. Set
Font(name="Calibri", ...)explicitly, and installfonts-crosextra-carlito, which has Calibri's metrics, on Linux. - Other sheets in the PDF. LibreOffice exports every visible sheet; hide the others on the copy.
- Hidden active sheet. Make the target sheet active before hiding the rest; a file whose active sheet is hidden is a state Excel never writes, and readers disagree about what to show.
- Page breaks. A tall range spills onto page two;
-singlefilethen keeps only the first page. SetfitToHeight = 1for ranges that must stay in one image. - Colours washed out. Cell fills print only if the sheet is not set to black-and-white printing; check
page_setup.blackAndWhiteon templates that were set up for an office printer.
Performance notes
Starting LibreOffice dominates the cost — typically around a second per conversion on a warm machine, more on the first run while it creates its profile. For many images, render one workbook with each range on its own sheet and page, then split the PDF with pdftoppm (which writes one PNG per page without -singlefile), rather than starting LibreOffice once per range.
Conclusion
To render a range without Excel, work through printing: on a copy of the workbook, show only the target sheet, set the print area to the range and fit it to the page width; convert to PDF with headless LibreOffice using a private profile; rasterise with pdftoppm at the resolution you need; and crop the white margins with Pillow. On Windows with Excel, Range.to_png in xlwings gives the most faithful picture. Either way, send the picture for reading and the workbook for detail.
Frequently asked questions
How do I save an Excel range as an image in Python? Without Excel, set the print area to the range with openpyxl, convert the workbook to PDF with headless LibreOffice, rasterise the page with pdftoppm and crop the whitespace with Pillow. With Excel on Windows, xlwings' Range.to_png writes the image directly.
Can openpyxl export a range to an image? No. openpyxl reads and writes the file format but has no rendering engine. It is used here only to set the print area and page setup before another program does the rendering.
Why does my rendered table use the wrong font? The renderer uses the fonts installed on the machine. If a cell font is not set, or the named font is missing, a substitute is used. Set font names explicitly and install metric-compatible fonts such as Carlito for Calibri on Linux.
How do I get a higher-resolution image? Raise the dpi passed to pdftoppm, for example -r 300. The PDF is vector, so the text stays sharp at any resolution; only the file size grows.
Related
- Up one level: Inserting Images and Logos into Excel — pictures into and out of workbooks.
- Export an Excel Chart as an Image — the same job for charts.
- Convert Excel to PDF on Linux with LibreOffice — the conversion step in depth.
- Fit an Excel Sheet to One Page in PDF — scaling options for the print step.
More on charts & images
- How-toBuild a Dashboard Sheet with Charts from Multiple Tabs
- How-toBuild a Rolling 12-Month Chart on an Excel Dashboard
- How-toKeep Charts and Images When Filling an Excel Template
- How-toAdd a Chart to an Excel File with xlsxwriter
- How-toAdd Sparklines to an Excel Report with xlsxwriter
- How-toBuild a Waterfall Chart in Excel with Python