Fix: LibreOffice PDF Fonts Substituted
The DOCX looks correct in Word. Converted on the server, the PDF comes back in a different typeface:
soffice --headless --convert-to pdf --outdir out/ in/contract.docx
Headings that were Calibri are now something narrower, the brand font has become a generic sans, and — the part that actually breaks things — a two-page contract is now three pages, because the substituted font has different metrics and the text reflows.
Root Cause
DOCX files reference fonts by name; they do not contain them unless the author explicitly enabled font embedding, which Word does not do by default. When LibreOffice renders the document it asks fontconfig for the named font, and on a minimal server or container image that font is not installed. Rather than fail, fontconfig returns the closest match it has — usually Liberation Sans for Arial, Carlito for Calibri, DejaVu Sans for anything else. Liberation and Carlito are metric-compatible substitutes, so line breaks are preserved; DejaVu is not, so the moment a brand font falls back to DejaVu the text reflows and page counts change. The visible symptom is a wrong typeface; the expensive symptom is a contract whose signature block has moved to a new page.
Minimal Diagnostic
Ask fontconfig what each font in the document actually resolves to, and check what the produced PDF embedded.
# pip install python-docx pypdf
import subprocess
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from pypdf import PdfReader
def fonts_named_in(path: Path) -> set[str]:
doc = Document(path)
names = {run.font.name for paragraph in doc.paragraphs for run in paragraph.runs if run.font.name}
for style in doc.styles:
if getattr(style, "font", None) and style.font.name:
names.add(style.font.name)
for element in doc.element.body.iter(qn("w:rFonts")):
for attribute in ("w:ascii", "w:hAnsi", "w:cs"):
value = element.get(qn(attribute))
if value:
names.add(value)
return names
def resolves_to(family: str) -> str:
result = subprocess.run(["fc-match", family], capture_output=True, text=True)
return result.stdout.strip() or "fc-match unavailable"
def embedded_fonts(pdf: Path) -> set[str]:
found = set()
for page in PdfReader(pdf).pages:
resources = page.get("/Resources", {})
for font in (resources.get("/Font") or {}).values():
obj = font.get_object()
base = str(obj.get("/BaseFont", "?")).lstrip("/")
found.add(base.split("+")[-1])
return found
if __name__ == "__main__":
docx = Path("in/contract.docx")
for family in sorted(fonts_named_in(docx)):
print(f" {family:<24} -> {resolves_to(family)}")
pdf = Path("out/contract.pdf")
if pdf.exists():
print(" embedded in PDF:", sorted(embedded_fonts(pdf)))
Calibri -> Carlito.ttf: "Carlito" "Regular"
Calibri Light -> DejaVuSans.ttf: "DejaVu Sans" "Book"
BrandSans -> DejaVuSans.ttf: "DejaVu Sans" "Book"
embedded in PDF: ['Carlito', 'DejaVuSans', 'LiberationSerif']
fc-match returning a different family name than the one asked for is the whole diagnosis. Calibri is safe here — Carlito is metric-compatible — but Calibri Light and the brand font both collapse to DejaVu Sans, which is where the reflow comes from.
Fix: Install the Fonts the Documents Actually Use
Put the real font files on the machine that converts, and rebuild the font cache so fontconfig sees them.
# Dockerfile for the conversion worker
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libreoffice-writer \
fonts-liberation \
fonts-crosextra-carlito \
fonts-crosextra-caladea \
fontconfig \
&& rm -rf /var/lib/apt/lists/*
COPY fonts/ /usr/local/share/fonts/brand/
RUN chmod -R a+r /usr/local/share/fonts/brand \
&& fc-cache -f -v > /dev/null \
&& fc-match "BrandSans" | grep -qi brandsans # fail the build if it did not take
ENV HOME=/tmp
WORKDIR /app
COPY convert.py .
fonts-crosextra-carlito and fonts-crosextra-caladea are the metric-compatible open substitutes for Calibri and Cambria, and fonts-liberation covers Arial, Times New Roman and Courier New. Those three packages fix the majority of Office documents without any licensing question. Licensed brand fonts have to be copied in — check the licence permits server use — and chmod a+r matters because a font the worker process cannot read is a font fontconfig will skip silently.
The fc-match line at the end of the build is what turns a silent regression into a failed build. Without it, a typo in the font filename produces an image that converts happily and wrongly.
Variant Fix 1: Force Embedding So the PDF Is Self-Contained
Installing the font fixes rendering; embedding it in the PDF keeps it correct wherever the file is opened. LibreOffice embeds by default, but the filter options make it explicit:
# stdlib only
import subprocess
from pathlib import Path
FILTER = 'pdf:writer_pdf_Export:{"EmbedStandardFonts":{"type":"boolean","value":"true"},' \
'"UseTaggedPDF":{"type":"boolean","value":"true"},' \
'"SelectPdfVersion":{"type":"long","value":"2"}}' # 2 = PDF/A-2b
def convert(docx: Path, outdir: Path, profile: Path) -> Path:
outdir.mkdir(parents=True, exist_ok=True)
subprocess.run(
["soffice", "--headless", f"-env:UserInstallation=file://{profile}",
"--convert-to", FILTER, "--outdir", str(outdir), str(docx)],
check=True, capture_output=True, timeout=180)
return outdir / f"{docx.stem}.pdf"
PDF/A refuses to produce a file with unembedded fonts, so requesting SelectPdfVersion: 2 turns a font problem into a conversion error rather than a silently degraded PDF. That is the right trade for contracts and invoices, and the wrong one for throwaway previews. The separate user profile keeps concurrent conversions from fighting over one LibreOffice profile directory, which matters in batch conversion.
Variant Fix 2: Normalise Fonts in the DOCX Before Converting
When the fonts cannot be installed — a licence that forbids server deployment, or documents arriving from customers naming fonts nobody has — rewrite the document to a font that is available:
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
SAFE = {"Calibri Light": "Carlito", "BrandSans": "Liberation Sans", "Helvetica Neue": "Liberation Sans"}
def normalise_fonts(src: Path, dst: Path, mapping: dict[str, str] = SAFE) -> int:
doc = Document(src)
changes = 0
for element in doc.element.iter(qn("w:rFonts")):
for attribute in ("w:ascii", "w:hAnsi", "w:cs", "w:eastAsia"):
current = element.get(qn(attribute))
if current in mapping:
element.set(qn(attribute), mapping[current])
changes += 1
for style in doc.styles:
font = getattr(style, "font", None)
if font is not None and font.name in mapping:
font.name = mapping[font.name]
changes += 1
doc.save(dst)
return changes
Rewriting the document is honest about what will happen: the substitution becomes a decision made once, visible in the file, instead of a surprise that differs between machines. Map to a metric-compatible face where one exists so the layout holds; where it does not, expect reflow and check the page count afterwards.
Pinning the Fonts So They Stay Pinned
Fonts drift. A base-image bump, a slimmer builder, a colleague trimming packages to shave fifty megabytes — any of these can remove a font without anybody noticing until a customer questions a contract. Record the expected resolution and check it at startup:
# stdlib only
import subprocess
REQUIRED = {"Calibri": "Carlito", "Arial": "Liberation Sans", "BrandSans": "BrandSans"}
def check_fonts() -> list[str]:
problems = []
for requested, expected in REQUIRED.items():
match = subprocess.run(["fc-match", requested], capture_output=True, text=True).stdout
if expected.lower() not in match.lower():
problems.append(f"{requested!r} resolves to {match.strip()!r}, expected {expected!r}")
return problems
if __name__ == "__main__":
for problem in check_fonts():
print("FONT:", problem)
Run it as a container health check or as the first thing the worker does. A conversion service that refuses to start with the wrong fonts installed is far cheaper than one that keeps producing subtly wrong PDFs, because the wrong output usually reaches a customer before it reaches a developer.
Verification
Assert on the produced PDF: the expected families are embedded, nothing unexpected crept in, and the page count matches.
# pip install pypdf
from pathlib import Path
from pypdf import PdfReader
ALLOWED = {"Carlito", "Carlito-Bold", "LiberationSerif", "LiberationSans", "BrandSans", "BrandSans-Bold"}
def verify_pdf_fonts(pdf: Path, expected_pages: int | None = None) -> None:
reader = PdfReader(pdf)
embedded, not_embedded = set(), set()
for page in reader.pages:
for font in ((page.get("/Resources") or {}).get("/Font") or {}).values():
obj = font.get_object()
name = str(obj.get("/BaseFont", "?")).lstrip("/").split("+")[-1]
descriptor = obj.get("/FontDescriptor")
if descriptor is None and "/DescendantFonts" in obj:
descriptor = obj["/DescendantFonts"][0].get_object().get("/FontDescriptor")
has_file = descriptor is not None and any(
key in descriptor for key in ("/FontFile", "/FontFile2", "/FontFile3"))
(embedded if has_file else not_embedded).add(name)
assert not not_embedded, f"not embedded: {sorted(not_embedded)}"
unexpected = embedded - ALLOWED
assert not unexpected, f"unexpected font(s) substituted: {sorted(unexpected)}"
if expected_pages is not None:
assert len(reader.pages) == expected_pages, \
f"{len(reader.pages)} pages, expected {expected_pages} — layout shifted"
print(f"{pdf.name}: {len(reader.pages)} pages, fonts {sorted(embedded)}")
if __name__ == "__main__":
verify_pdf_fonts(Path("out/contract.pdf"), expected_pages=2)
The page-count assertion is the one that catches silent damage. A PDF with the wrong font but the right page count is cosmetically annoying; a PDF whose signature block slid onto page three is a business problem, and only the count reveals it. Run this check in the conversion pipeline, not just in testing — a base-image rebuild that drops a font package fails loudly on the next document instead of six weeks later.
FAQ
Why does fc-match report the right font but the PDF still looks wrong?
Check the process user. Fonts under a user's ~/.fonts are invisible to a service running as a different user; install into /usr/local/share/fonts instead.
Do I have to run fc-cache after copying fonts?
Yes, in an image build. At runtime fontconfig will rescan, but caching in the build makes the first conversion fast and the failure detectable early.
Can I embed fonts in the DOCX instead? Word can, but LibreOffice's support for reading embedded DOCX fonts is incomplete. Installing on the converter is more reliable.
Why are some glyphs boxes? That is a font that lacks those characters, not a substitution — common with Wingdings, Symbol and non-Latin scripts. Install a font covering the script.
Related
- Converting DOCX to PDF with Python — the conversion workflow end to end
- Batch Convert DOCX to PDF in Parallel — running many conversions safely
- Fix LibreOffice Headless Conversion Timeout — when conversion hangs instead of failing
- Set Fonts and Styles with python-docx — choosing fonts when generating the document
Part of Converting DOCX to PDF with Python.