Merge Several Excel PDF Exports into One File
A monthly pack is often several separate reports — one workbook per region, a summary, an appendix — that someone wants as a single PDF to read or forward. Converting each workbook and merging the PDFs is quick with pypdf, and the result can be made pleasant to navigate with bookmarks and page labels. It also quietly loses two things people notice later: the accessibility tags and continuous page numbering. This guide, part of Exporting Excel Reports to PDF, merges exports properly, measures what changes, and shows when to combine the workbooks before converting instead.
Prerequisites
pip install pypdf pandas openpyxl
The exports come from LibreOffice, as in Convert Excel to PDF on Linux with LibreOffice. Four regional workbooks of different lengths stand in for the real reports:
import pandas as pd
regions = ["North", "South", "East", "West"]
for i, region in enumerate(regions):
rows = 60 + i * 45
pd.DataFrame({
"store": [f"{region} {k:03d}" for k in range(rows)],
"sales": range(rows),
}).to_excel(f"{region}.xlsx", index=False)
Step 1 — Convert all the workbooks in one call
LibreOffice accepts several input files in one command. Starting it once instead of once per file saves its start-up time on every file — 0.5 seconds against 1.7 for these four, and the gap grows with the number of reports:
import subprocess
subprocess.run(
["soffice", "--headless", "--convert-to", "pdf", "--outdir", "pdf"]
+ [f"{region}.xlsx" for region in regions],
check=True, timeout=300,
)
The four PDFs came out at 2, 3, 4 and 5 pages. Page setup — orientation, fitting wide sheets, repeated headers — has to be right in each workbook before this step; Fit an Excel Sheet to One Page Wide When Exporting to PDF covers it.
Step 2 — Merge with a bookmark per report
PdfWriter.append copies every page of a file onto the end of the output. Its outline_item argument adds a bookmark pointing at the first appended page, so the reader can jump straight to a region:
from pypdf import PdfReader, PdfWriter
writer = PdfWriter()
for region in regions:
writer.append(f"pdf/{region}.pdf", outline_item=region)
writer.write("regional_pack.pdf")
pack = PdfReader("regional_pack.pdf")
print(len(pack.pages)) # 14
LibreOffice already writes a bookmark per sheet into each export, and append imports those by default, nesting them under the new bookmark. With one sheet per workbook that produces a pointless "Sheet1" under every region; pass import_outline=False to leave them out, or keep them when each workbook has several meaningful sheets.
Step 3 — Page labels, a title and an open bookmarks panel
A 14-page merged file shows "page 7 of 14" in the viewer, which says nothing about where the reader is. PDF page labels replace those numbers with anything you like — here the region and the page within it. A document title and a page mode that opens the bookmarks panel finish the job:
writer = PdfWriter()
for region in regions:
start = len(writer.pages)
writer.append(f"pdf/{region}.pdf", outline_item=region, import_outline=False)
writer.set_page_label(start, len(writer.pages) - 1, style="/D", prefix=f"{region} ", start=1)
writer.add_metadata({"/Title": "Regional sales, September 2026"})
writer.page_mode = "/UseOutlines" # open with bookmarks showing
writer.write("regional_pack.pdf")
pack = PdfReader("regional_pack.pdf")
print(pack.page_labels[:5], pack.page_labels[-1])
# ['North 1', 'North 2', 'South 1', 'South 2', 'South 3'] West 5
Viewers show the title in the window bar instead of the file name, and "South 2" in the page box. Page labels change what the viewer displays, not what is printed on the page — footers from the original exports still say whatever they said.
Step 4 — Know what the merge drops
The merged file was 42 KB; the four inputs totalled 337 KB. Nothing visible was lost — every page and its fonts are there — but pdfinfo tells the story: the exports are tagged, carrying a structure tree that screen readers use to read tables in order, and the merged file is not. append copies pages, not the document-level structure that describes them. For internal packs this rarely matters; for documents that must meet accessibility requirements, it rules the merge out.
The second loss is page numbering. A footer of "Page &P of &N" in each workbook printed "Page 1 of 3" on South's first page, and after merging it still does, on page 3 of 14. Page labels help on screen; they do not change the printed footer.
Deduplicating does not win the size back either. Each export embeds its own subset of the same font with different glyphs, so writer.compress_identical_objects() found nothing to remove in this test.
Step 5 — Or combine the workbooks and convert once
When the reports are generated by your own code, write them as sheets of one workbook and convert that. LibreOffice then produces one tagged PDF, a bookmark per sheet, and page numbers that run through the whole document:
from openpyxl import load_workbook
with pd.ExcelWriter("regional_pack.xlsx") as writer:
for region in regions:
pd.read_excel(f"{region}.xlsx").to_excel(writer, sheet_name=region, index=False)
wb = load_workbook("regional_pack.xlsx")
for ws in wb.worksheets:
ws.oddFooter.center.text = "Page &P of &N"
wb.save("regional_pack.xlsx")
print(wb.sheetnames)
# ['North', 'South', 'East', 'West']
Converted, this gave a 14-page, tagged PDF whose footers read "Page 3 of 14" on the third page and "Page 14 of 14" on the last, with bookmarks named after the sheets. The catch is formatting: copying data through pandas loses styles, and openpyxl cannot copy a sheet from one workbook into another. This route suits reports you build from data; for finished workbooks from different sources, merging PDFs is the practical option. Headers and footers are covered in Add Headers, Footers and Page Numbers to an Excel PDF.
Step 6 — Check the result
Verify the merged document before it is sent: the page count must equal the sum of the parts, and every bookmark must point where it should:
parts = [len(PdfReader(f"pdf/{region}.pdf").pages) for region in regions]
pack = PdfReader("regional_pack.pdf")
assert len(pack.pages) == sum(parts), (len(pack.pages), parts)
expected, page = {}, 0
for region, count in zip(regions, parts):
expected[region] = page
page += count
found = {item.title: pack.get_destination_page_number(item) for item in pack.outline}
assert found == expected, found
Assertions like these catch the usual mistakes — a missing export, a stale PDF from the previous month in the folder, files merged in directory order instead of the intended one.
Common pitfalls
- Merging in directory order.
globreturns files in arbitrary order. Build the list explicitly, in the order readers expect. - Stale exports. If a conversion fails, last month's PDF may still be in the output folder and gets merged. Empty the folder before converting, and check each PDF is newer than its workbook.
- Encrypted inputs. A password-protected PDF must be decrypted with
PdfReader(...).decrypt(password)before its pages can be appended. - Very large packs.
appendholds the output in memory untilwrite. For hundreds of reports, merge in batches. - Bookmark titles from file names. Names such as
rpt_north_final2end up in the reader's bookmarks panel. Map each file to a proper title — "North region" — when building the list. - Writing over the pack that is being read. Writing
regional_pack.pdfwhile a colleague has it open fails on Windows and can leave a truncated file on a share. Write to a temporary name in the same folder and rename it when complete.
Conclusion
Convert all the workbooks in one LibreOffice call, merge the PDFs with PdfWriter.append and a bookmark per report, and add page labels, a title and an open bookmarks panel so the pack is easy to navigate. Merging drops the accessibility tags and keeps each export's own page numbers; when those matter and the reports are built from data, write them as sheets of one workbook and convert once.
Frequently asked questions
How do I merge PDF files in Python? Use pypdf's PdfWriter. Call writer.append(path, outline_item="Name") for each file in order, then writer.write("merged.pdf"). The outline_item argument adds a bookmark pointing at the first page of that file.
Why is the merged PDF much smaller than the files I merged? LibreOffice exports tagged PDFs that include a structure tree for screen readers. pypdf's append does not carry that tree over, so the merged file is untagged. In our test four exports totalling 337 KB merged into 42 KB.
How do I get page numbers that continue across the merged reports? Footers printed by each export restart at 1. Either put all the sheets into one workbook and convert it once, which numbered pages 1 to 14 continuously in our test, or use page labels so the PDF viewer shows section-based numbers such as "South 2".
Can I keep each workbook's sheet bookmarks when merging? Yes. pypdf imports the source file's outline by default and nests it under the bookmark you pass as outline_item. Pass import_outline=False to leave them out.
Related
- Up one level: Exporting Excel Reports to PDF — turning workbooks into PDF reports.
- Convert Only Selected Sheets of a Workbook to PDF — choosing what goes into each export.
- Convert an Excel File to PDF with Python — the conversion options on Windows and Linux.
- Zip Large Excel Attachments Before Emailing Them — sending the finished pack.
More on pdf export
- How-toFill PDF Forms from Excel Rows with Python
- How-toGenerate One PDF per Excel Row
- How-toAdd Bookmarks and a Cover Page to an Excel PDF Export
- How-toAdd Headers, Footers and Page Numbers to an Excel PDF
- How-toCheck a PDF Export for Cut-Off Columns
- How-toConvert an Excel File to PDF with Python