Export an Excel Report to PDF/A for Archiving
Month-end packs, statutory reports and signed-off reconciliations often have to be kept for years — seven or ten is common for finance records — and auditors, regulators or records-management systems may ask for them in PDF/A. PDF/A is an ISO-standardised subset of PDF designed so a file will display the same way decades from now: every font is embedded, colours are defined device-independently, and features that depend on something outside the file — external links to content, JavaScript, encryption — are forbidden. An ordinary "save as PDF" usually breaks at least one rule, typically by not declaring an output colour profile. This page exports a workbook to PDF/A with LibreOffice from Python, sets the metadata an archive will index, checks the result for the markers a conforming file must carry, and shows how easily a later processing step can undo conformance. It belongs to Exporting Excel Reports to PDF.
Prerequisites
pip install openpyxl pypdf
# LibreOffice 7.4 or later for the JSON-style export options used below
LibreOffice does the conversion; pypdf reads the result to check it. The checks below test the most common failure points, but they are not a full validation — for that, run the free veraPDF validator, the reference implementation used by archives, on a sample of your exports when you set the process up and whenever it changes.
Step 1 — A report with proper document properties
Archive systems index a PDF by its metadata, and LibreOffice copies the workbook's title and author into the PDF. Set them deliberately rather than leaving the library's defaults:
from openpyxl import Workbook
from openpyxl.styles import Font
wb = Workbook()
ws = wb.active
ws.title = "Trial balance"
ws.append(["Account", "Name", "Debit", "Credit"])
for c in ws[1]:
c.font = Font(bold=True)
for row in [[1000, "Cash at bank", 182450.10, None], [1200, "Trade debtors", 96210.00, None],
[2100, "Trade creditors", None, 71320.55], [3000, "Share capital", None, 50000.00],
[4000, "Sales — Café Señor account", None, 157339.55]]:
ws.append(row)
for col, width in zip("ABCD", (10, 30, 14, 14)):
ws.column_dimensions[col].width = width
wb.properties.title = "Trial balance — September 2026"
wb.properties.creator = "Finance reporting"
wb.properties.subject = "Month-end close"
wb.save("trial_balance.xlsx")
Without creator, openpyxl writes "openpyxl" as the author, and that is what the archive would record. The accented account name is there on purpose: characters outside plain ASCII are where font embedding problems show up first.
Step 2 — Export with the PDF/A option
LibreOffice's PDF export takes a SelectPdfVersion option: 1 for PDF/A-1b, 2 for PDF/A-2b and 3 for PDF/A-3b. From LibreOffice 7.4 options can be passed as JSON after the filter name:
import json
import subprocess
def to_pdfa(xlsx, outdir="pdf", part=2):
options = {"SelectPdfVersion": {"type": "long", "value": str(part)}}
target = f"pdf:calc_pdf_Export:{json.dumps(options)}"
subprocess.run(["soffice", "--headless", "--convert-to", target, "--outdir", outdir, xlsx],
check=True, capture_output=True, timeout=180)
return f"{outdir}/{xlsx.rsplit('.', 1)[0]}.pdf"
archived = to_pdfa("trial_balance.xlsx")
subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "--outdir", "plain", "trial_balance.xlsx"],
check=True, capture_output=True, timeout=180)
print(archived, "and plain/trial_balance.pdf written")
Which part to choose depends on who will read the file. PDF/A-1b, based on PDF 1.4, is the most widely accepted and the most restrictive — no transparency, no layers. PDF/A-2b, based on PDF 1.7, allows transparency and compression improvements and is a sensible default when the archive accepts it. PDF/A-3b is PDF/A-2 plus the ability to embed arbitrary files, such as the source workbook; it exists for that purpose and some archives deliberately reject it. The "b" means basic conformance — the file is guaranteed to look right — as opposed to "a" (accessible), which additionally requires a complete tagged structure. The rest of the conversion process — profiles, timeouts, running several conversions at once — is covered in Convert Excel to PDF on Linux with LibreOffice.
Step 3 — Check the markers a PDF/A file must carry
A conforming file identifies itself in its XMP metadata, declares an output intent with an embedded colour profile, embeds every font, and is not encrypted. pypdf can read all four:
import re
from pypdf import PdfReader
def pdfa_report(path):
reader = PdfReader(path)
encrypted = reader.is_encrypted
if encrypted:
reader.decrypt("") # an empty user password still opens it for reading
root = reader.trailer["/Root"]
meta = root.get("/Metadata")
xmp = meta.get_object().get_data().decode("utf-8", "replace") if meta else ""
part = re.search(r"<pdfaid:part>(\d)</pdfaid:part>", xmp)
conf = re.search(r"<pdfaid:conformance>(\w)</pdfaid:conformance>", xmp)
fonts = {}
for page in reader.pages:
for font in page["/Resources"].get("/Font", {}).values():
font = font.get_object()
desc = font.get("/FontDescriptor") or font["/DescendantFonts"][0].get_object()["/FontDescriptor"]
fonts[font["/BaseFont"]] = any(k.startswith("/FontFile") for k in desc.get_object())
return {"pdfa": f"{part.group(1)}{conf.group(1).lower()}" if part and conf else None,
"output intent": "/OutputIntents" in root, "fonts embedded": all(fonts.values()) if fonts else None,
"encrypted": encrypted, "title": reader.metadata.title, "author": reader.metadata.author}
for path in [archived, "plain/trial_balance.pdf"]:
print(path, pdfa_report(path))
The PDF/A export identifies itself as part 2, conformance B, has an output intent and embedded fonts, is not encrypted, and carries the title and author from the workbook. The plain export embeds the fonts too — LibreOffice always subsets and embeds them — but has no PDF/A identification and no output intent, so an archive's validator would reject it. The decrypt("") line lets the same function inspect files that are encrypted with only an owner password, which opens them for reading but still counts as encryption.
A check like this belongs in the job itself, after every export: it costs milliseconds, and it turns "we assume the archive accepted it" into a logged fact.
Two content issues are worth knowing about even though they do not show up in these checks. Images in the workbook — a logo in the header, a chart — are converted along with the cells; transparency in them is fine for PDF/A-2 and later but has to be flattened for PDF/A-1, which LibreOffice does automatically at some cost in quality, another reason to prefer part 2. And hyperlinks to web pages remain clickable but the content they point to is, by definition, not archived: if a figure in the report depends on a linked document, put that document in the archive too rather than relying on the link.
Step 4 — Do not break it afterwards
Conformance is a property of the exact bytes. Many ordinary post-processing steps produce a new file that is no longer PDF/A, without any error:
from pypdf import PdfWriter
writer = PdfWriter()
writer.append(archived)
writer.append(archived)
writer.write("merged.pdf")
print("merged:", pdfa_report("merged.pdf"))
writer = PdfWriter(clone_from=archived)
writer.encrypt(user_password="", owner_password="owner-secret")
writer.write("encrypted.pdf")
print("encrypted:", pdfa_report("encrypted.pdf"))
Merging two PDF/A files with pypdf produces a file with no PDF/A identification, no output intent and not even the title and author — all of them live in the document catalogue and information dictionary, which a merge rebuilds from scratch. Cloning the whole document keeps them, but encrypting it, even with an empty user password just to restrict printing, violates the standard regardless of what the metadata still claims; the check above flags it. The same applies to stamping, page numbering and watermarking with general PDF tools. The rule for archive copies is simple: make every change in the workbook — headers, footers, page numbers, a cover sheet — and convert last. If several reports must become one archive file, combine the sheets into one workbook and export that, rather than merging PDFs; Merge Several Excel PDF Exports into One File is the right approach for distribution copies, not archive copies.
Common pitfalls
- Assuming any PDF is archival. Without the PDF/A option there is no output intent or identification.
- Default metadata. openpyxl writes "openpyxl" as the author unless you set
creator. - Post-processing the archive copy. Merging, stamping or encrypting can break conformance.
- Password protection. Encryption is forbidden in every PDF/A part.
- Trusting the marker alone. The identification is a claim; validate samples with veraPDF.
Conclusion
To archive Excel reports as PDF/A, set the workbook's title and author, then export with LibreOffice using SelectPdfVersion — 2 for PDF/A-2b is a good default, 1 when the archive demands PDF/A-1b. Check each export for the PDF/A identification, an output intent, embedded fonts and no encryption, validate samples with veraPDF, and make every change in the workbook before converting, because merging, stamping or encrypting the PDF afterwards breaks conformance.
Frequently asked questions
How do I convert an Excel file to PDF/A in Python? Run LibreOffice headless with a convert target such as pdf:calc_pdf_Export with the SelectPdfVersion option set to 1, 2 or 3 for PDF/A-1b, 2b or 3b.
Which PDF/A version should I use for reports? PDF/A-2b is a good default; use PDF/A-1b if the archive requires it, and PDF/A-3b only when the source file must be embedded and the archive accepts it.
How can I check whether a PDF is PDF/A? Look for pdfaid part and conformance in the XMP metadata and an OutputIntents entry in the catalogue, check the fonts are embedded and the file is not encrypted, and validate with veraPDF.
Can I password-protect a PDF/A file? No. Encryption is not allowed in any PDF/A part; control access through where the file is stored instead.
Related
- Up one level: Exporting Excel Reports to PDF — PDFs from Excel in Python.
- Convert an Excel File to PDF with Python — the conversion options.
- Add Headers, Footers and Page Numbers to an Excel PDF — changes to make before converting.
- Password-Protect a PDF Exported from Excel — for distribution copies only.
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