QA How-To
Validate PDF Font Embedding Automatically (2026)
Learn to validate pdf font embedding automatically with Python, Poppler, policy-aware reports, test fixtures, and a strict GitHub Actions CI release gate.
24 min read | 3,266 words
TL;DR
Walk every page resource and nested Form XObject, resolve each font's descriptor, and require a `/FontFile`, `/FontFile2`, or `/FontFile3` stream. Treat Type 3 glyph programs separately, make Base 14 exceptions explicit, emit JSON, and return exit code 1 when any required font is not embedded.
Key Takeaways
- Inspect PDF font dictionaries and embedded font streams instead of trusting filenames, visual appearance, or successful text extraction.
- Treat subset fonts as embedded, but report the subset state because later document editing may need glyphs that are absent.
- Resolve Type 0 descendant font descriptors and recurse into Form XObjects so nested composite fonts are not missed.
- Make the Base 14 exception an explicit policy switch because strict archival and portable-output rules should reject those unembedded fonts.
- Generate one known-good and one known-bad PDF fixture so the validator's pass and fail paths are both proven.
- Return deterministic exit codes and JSON evidence so CI can block a release and preserve a diagnosable report.
- Pair embedding checks with rendering, text, accessibility, and conformance tests because embedding alone does not prove document quality.
To validate pdf font embedding automatically, inspect the PDF objects that define each font and fail when a required font has no embedded program. A PDF can look correct on the machine that created it while silently substituting fonts elsewhere, so opening the file or extracting its text is not a reliable embedding test.
This tutorial builds a Python command that scans simple fonts, Type 0 composite fonts, and fonts referenced inside Form XObjects. It produces a readable console result, writes JSON evidence, returns useful exit codes, and runs as a GitHub Actions release gate. If your automated flow first downloads the document through a browser, connect this check to the Playwright downloaded PDF assertion guide after the file reaches disk.
What You Will Build
You will create a small pdf-font-gate project that can:
- classify full embedded fonts, embedded subsets, self-contained Type 3 fonts, permitted Base 14 fonts, and missing font programs;
- find font resources on pages and inside nested Form XObjects;
- scan one PDF or every PDF below a directory;
- save a machine-readable JSON report while printing a concise human result;
- return
0for a pass,1for a policy failure, and2for invalid input or an unreadable PDF; - prove itself against generated pass and fail fixtures before checking product artifacts; and
- block a pull request or export job when a font is not portable.
The check answers one narrow question: are required font programs present inside the PDF? It does not prove that glyphs render correctly, text maps to Unicode, licensing permits embedding, or the document conforms to PDF/A. Those are separate test oracles.
Prerequisites
Use these exact versions for a reproducible 2026 setup:
| Component | Version used here | Purpose |
|---|---|---|
| CPython | 3.14.5 | Runs the validator and fixtures |
| pikepdf | 10.11.0 | Reads low-level PDF dictionaries through qpdf |
| ReportLab | 5.0.0 | Generates deterministic good and bad fixtures |
| pytest | 9.1.1 | Exercises the validator API and CLI behavior |
| Poppler | 26.07.0 | Supplies the independent pdffonts diagnostic |
Create the project and virtual environment:
mkdir pdf-font-gate
cd pdf-font-gate
python3.14 -m venv .venv
source .venv/bin/activate
mkdir -p scripts tests samples reports artifacts
On Windows PowerShell, activate with .venv\Scripts\Activate.ps1. Install Poppler 26.07.0 through your approved package channel or container image, then confirm that pdffonts is on PATH. Package managers may publish a distribution revision around the same upstream Poppler release, so record both values in regulated build evidence.
Verification: Run python --version, pdffonts -v, and pwd. The Python line must show 3.14.5, the Poppler output must identify 26.07.0, and the current directory must end in pdf-font-gate.
Step 1: Define the PDF Font Embedding Policy
Write the acceptance rule before writing the scanner. A strict portable-output policy requires every used font program to travel with the PDF. An embedded subset is acceptable for read-only delivery because the file contains the glyphs used at generation time. A full font is useful when later editing may introduce more characters, but it increases size and may be prohibited by the font's license.
Type 3 fonts need separate handling. Their glyph descriptions live in the PDF's /CharProcs objects rather than a /FontFile* stream, so a generic descriptor-only test would incorrectly reject them. Type 0 fonts also need special handling: the top-level composite dictionary usually points to one or more /DescendantFonts, and the actual /FontDescriptor belongs to the descendant.
| Font result | Strict delivery | Editable-template policy | Reason |
|---|---|---|---|
| Embedded full font | Pass | Pass | Complete program is present |
| Embedded subset | Pass | Review or fail | Current glyphs are present, future glyphs may not be |
Type 3 with /CharProcs |
Pass | Review | Glyph programs are self-contained but specialized |
| Unembedded Base 14 font | Fail by default | Fail | Viewer substitution is still an external dependency |
| Other unembedded font | Fail | Fail | Rendering depends on a font outside the document |
Do not assume Helvetica, Times, Courier, Symbol, or ZapfDingbats are always safe. Older workflows treated the 14 standard fonts as viewer-provided. A strict gate rejects them unless your documented consumer contract deliberately permits that dependency. The command below exposes --allow-base14 for that exception instead of hiding it in code.
Record the rule in FONT_POLICY.md:
# PDF font policy
- Every delivered PDF must contain each required font program.
- Embedded subsets pass for immutable documents.
- Type 3 fonts pass only when `/CharProcs` is present.
- Base 14 fonts fail unless a named workflow invokes `--allow-base14`.
- An unreadable or encrypted PDF fails closed.
- The CI JSON report is retained with the document build.
Verification: Run grep -c '^-' FONT_POLICY.md. The command must print 6. Review the fourth line with the document owner before enabling any Base 14 exception.
Step 2: Establish a Manual Baseline With pdffonts
Before implementing Python, learn the independent signal from Poppler. pdffonts lists each font's name, type, encoding, embedding state, subset state, Unicode mapping state, and PDF object ID. Run it against a real artifact if one is available:
pdffonts artifacts/invoice.pdf
A representative result looks like this:
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+Vera TrueType WinAnsi yes yes yes 7 0
Helvetica Type 1 WinAnsi no no no 2 0
The emb column is the embedding answer. sub tells you whether only selected glyphs were included. uni reports an explicit ToUnicode map, which affects reliable text interpretation but is not the same as embedding. A font can be embedded and still have uni set to no; conversely, a Unicode map does not supply a missing font program.
pdffonts is excellent for diagnosis and an independent check, but avoid parsing its aligned console table as your only long-term API. Names can contain spaces, localized environments can complicate output, and policy concepts such as permitted Base 14 fonts still need code. The Python implementation reads the PDF dictionaries directly and emits stable JSON. Keep pdffonts in the troubleshooting path because a second parser helps distinguish a validator defect from a producer defect.
If you test a file retrieved by Cypress, first verify the response, saved bytes, and document identity using Cypress PDF download assertions. Font inspection should run only after you know the checker received the intended file rather than an HTML error page with a .pdf extension.
Verification: Run pdffonts artifacts/invoice.pdf | sed -n '1,4p'. Confirm that the header contains emb, sub, and uni. If any data row contains no under emb, save the object ID because the automated report should identify the same underlying font object.
Step 3: validate pdf font embedding automatically with Python
Create scripts/pdf_font_check.py. The scanner resolves each page's inherited resources, records declared font dictionaries, and recursively visits Form XObjects. It deduplicates indirect objects while retaining every page and location where a font appears.
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Iterable
import pikepdf
from pikepdf import Name, Object
BASE14 = {
'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique',
'Helvetica-BoldOblique', 'Times-Roman', 'Times-Bold',
'Times-Italic', 'Times-BoldItalic', 'Symbol', 'ZapfDingbats',
}
SUBSET_PREFIX = re.compile(r'^[A-Z]{6}\+')
FONT_STREAM_KEYS = ('/FontFile', '/FontFile2', '/FontFile3')
def object_key(obj: Object) -> str:
number, generation = obj.objgen
if number:
return f'{number}:{generation}'
return f'direct:{id(obj)}'
def clean_name(value: Any, fallback: str) -> str:
if value is None:
return fallback
return str(value).removeprefix('/')
def assess_font(font: Object, allow_base14: bool) -> dict[str, Any]:
base_font = clean_name(font.get('/BaseFont'), 'unnamed')
subtype = clean_name(font.get('/Subtype'), 'unknown')
plain_name = SUBSET_PREFIX.sub('', base_font)
subset = bool(SUBSET_PREFIX.match(base_font))
if subtype == 'Type3':
embedded = font.get('/CharProcs') is not None
status = 'embedded_type3' if embedded else 'not_embedded'
else:
if subtype == 'Type0':
descendants = list(font.get('/DescendantFonts', []))
descriptors = [item.get('/FontDescriptor') for item in descendants]
else:
descriptors = [font.get('/FontDescriptor')]
descriptors = [item for item in descriptors if item is not None]
embedded = bool(descriptors) and all(
any(key in descriptor for key in FONT_STREAM_KEYS)
for descriptor in descriptors
)
if embedded:
status = 'embedded_subset' if subset else 'embedded_full'
elif plain_name in BASE14 and allow_base14:
status = 'allowed_base14'
else:
status = 'not_embedded'
number, generation = font.objgen
return {
'base_font': base_font,
'subtype': subtype,
'embedded': embedded,
'subset': subset,
'status': status,
'object_id': f'{number} {generation}' if number else 'direct',
'pages': [],
'locations': [],
}
def scan_resources(
resources: Object,
page_number: int,
location: str,
allow_base14: bool,
fonts: dict[str, dict[str, Any]],
seen_forms: set[str],
) -> None:
font_resources = resources.get('/Font', {})
for resource_name, font in font_resources.items():
key = object_key(font)
if key not in fonts:
fonts[key] = assess_font(font, allow_base14)
record = fonts[key]
if page_number not in record['pages']:
record['pages'].append(page_number)
use_location = f'{location}/Font {resource_name}'
if use_location not in record['locations']:
record['locations'].append(use_location)
for xobject_name, xobject in resources.get('/XObject', {}).items():
if xobject.get('/Subtype') != Name.Form:
continue
form_key = object_key(xobject)
if form_key in seen_forms:
continue
seen_forms.add(form_key)
nested = xobject.get('/Resources')
if nested is not None:
scan_resources(
nested, page_number, f'{location}/Form {xobject_name}',
allow_base14, fonts, seen_forms,
)
def validate_pdf(path: Path, allow_base14: bool = False) -> dict[str, Any]:
found: dict[str, dict[str, Any]] = {}
with pikepdf.Pdf.open(path) as pdf:
for page_number, page in enumerate(pdf.pages, start=1):
scan_resources(
page.resources, page_number, f'Page {page_number}',
allow_base14, found, set(),
)
fonts = sorted(
found.values(), key=lambda item: (item['base_font'], item['object_id'])
)
failures = [item for item in fonts if item['status'] == 'not_embedded']
return {
'file': str(path),
'passed': not failures,
'font_count': len(fonts),
'failure_count': len(failures),
'allow_base14': allow_base14,
'fonts': fonts,
}
def expand_inputs(inputs: Iterable[str]) -> list[Path]:
paths: list[Path] = []
for raw in inputs:
path = Path(raw)
if path.is_dir():
paths.extend(sorted(path.rglob('*.pdf')))
else:
paths.append(path)
return paths
def print_report(report: dict[str, Any]) -> None:
label = 'PASS' if report['passed'] else 'FAIL'
print(f"{label}: {report['file']} ({report['font_count']} fonts)")
for font in report['fonts']:
pages = ','.join(str(page) for page in font['pages'])
print(
f" {font['status']:<18} {font['base_font']} "
f"type={font['subtype']} pages={pages} object={font['object_id']}"
)
def main() -> int:
parser = argparse.ArgumentParser(description='Validate PDF font embedding')
parser.add_argument('inputs', nargs='+', help='PDF files or directories')
parser.add_argument('--allow-base14', action='store_true')
parser.add_argument('--report', type=Path, help='Write combined JSON evidence')
args = parser.parse_args()
paths = expand_inputs(args.inputs)
if not paths:
print('ERROR: no PDF files found', file=sys.stderr)
return 2
reports: list[dict[str, Any]] = []
input_error = False
for path in paths:
try:
report = validate_pdf(path, args.allow_base14)
reports.append(report)
print_report(report)
except (OSError, pikepdf.PdfError) as error:
input_error = True
reports.append({'file': str(path), 'passed': False, 'error': str(error)})
print(f'ERROR: {path}: {error}', file=sys.stderr)
payload = {
'passed': not input_error and all(item['passed'] for item in reports),
'documents': reports,
}
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
if input_error:
return 2
return 0 if payload['passed'] else 1
if __name__ == '__main__':
raise SystemExit(main())
The three font stream keys represent Type 1, TrueType, and other font program containers such as compact font format streams. Requiring every descendant descriptor prevents a malformed Type 0 font with one present and one missing program from passing. The scan is intentionally conservative: a declared but unused font resource still fails because stale dependencies should be removed by the producer, not silently ignored by the gate.
A Form XObject can be reused on several pages. The scanner resets seen_forms for each page so the report preserves all page references while still breaking cycles within that page's resource graph.
Verification: Run python -m py_compile scripts/pdf_font_check.py and then python scripts/pdf_font_check.py --help. The first command must exit 0; the help output must show --allow-base14 and --report.
Step 4: Generate Known-Good and Known-Bad PDF Fixtures
A validator is not credible if it has only been run against documents expected to pass. Create scripts/make_font_samples.py. ReportLab's built-in Helvetica creates the negative fixture without a font stream. Its bundled Vera TrueType file creates the positive fixture with an embedded subset.
from __future__ import annotations
from pathlib import Path
import reportlab
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
def build_samples(output_dir: Path) -> tuple[Path, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
good = output_dir / 'embedded-font.pdf'
bad = output_dir / 'unembedded-base14.pdf'
vera = Path(reportlab.__file__).parent / 'fonts' / 'Vera.ttf'
if not vera.is_file():
raise FileNotFoundError(f'ReportLab Vera fixture font not found: {vera}')
pdfmetrics.registerFont(TTFont('FixtureVera', str(vera)))
good_canvas = canvas.Canvas(
str(good), initialFontName='FixtureVera', initialFontSize=14,
)
good_canvas.drawString(72, 720, 'Embedded font fixture: INV-2048')
good_canvas.save()
bad_canvas = canvas.Canvas(str(bad))
bad_canvas.setFont('Helvetica', 14)
bad_canvas.drawString(72, 720, 'Unembedded Base 14 fixture: INV-2048')
bad_canvas.save()
return good, bad
if __name__ == '__main__':
created = build_samples(Path('samples'))
for path in created:
print(path)
Create pinned dependencies and install them:
# requirements.txt
pikepdf==10.11.0
reportlab==5.0.0
pytest==9.1.1
python -m pip install --requirement requirements.txt
python scripts/make_font_samples.py
python scripts/pdf_font_check.py samples/embedded-font.pdf
python scripts/pdf_font_check.py samples/unembedded-base14.pdf; test $? -eq 1
The positive fixture passes because initialFontName prevents ReportLab from declaring its default Helvetica resource before Vera is selected. This makes the fixture intent explicit. If a real producer leaves unused resources, the strict policy will expose them; fix the generator or document a separately tested used-resource policy instead of silently ignoring the finding.
The negative command intentionally expects exit code 1. Running it with --allow-base14 should pass and label Helvetica allowed_base14, demonstrating that the exception is both visible and opt-in.
Verification: Run pdffonts samples/embedded-font.pdf and confirm the Vera row has yes under emb. Run pdffonts samples/unembedded-base14.pdf and confirm Helvetica has no. Then verify python scripts/pdf_font_check.py samples/unembedded-base14.pdf --allow-base14 exits 0.
Step 5: Add Automated Tests for Detection and Exit Codes
Unit-level tests should call validate_pdf() directly so failures identify classification logic. One subprocess test covers the public CLI contract and protects the release gate from accidentally returning success on a failed document. Create tests/test_pdf_font_check.py:
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from scripts.make_font_samples import build_samples
from scripts.pdf_font_check import validate_pdf
def test_embedded_true_type_font_passes(tmp_path: Path) -> None:
good, _ = build_samples(tmp_path)
report = validate_pdf(good)
assert report['passed'] is True
assert report['failure_count'] == 0
assert any(font['embedded'] for font in report['fonts'])
assert any(font['subset'] for font in report['fonts'])
def test_unembedded_base14_fails_strict_policy(tmp_path: Path) -> None:
_, bad = build_samples(tmp_path)
report = validate_pdf(bad)
assert report['passed'] is False
assert report['failure_count'] >= 1
assert any(
font['base_font'] == 'Helvetica'
and font['status'] == 'not_embedded'
for font in report['fonts']
)
def test_base14_exception_is_explicit(tmp_path: Path) -> None:
_, bad = build_samples(tmp_path)
report = validate_pdf(bad, allow_base14=True)
assert report['passed'] is True
assert any(font['status'] == 'allowed_base14' for font in report['fonts'])
def test_cli_returns_one_and_writes_report(tmp_path: Path) -> None:
_, bad = build_samples(tmp_path)
report_path = tmp_path / 'font-report.json'
result = subprocess.run(
[
sys.executable, 'scripts/pdf_font_check.py', str(bad),
'--report', str(report_path),
],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 1
assert 'FAIL:' in result.stdout
assert report_path.is_file()
assert '"passed": false' in report_path.read_text(encoding='utf-8')
These tests avoid weak assertions such as checking only that a font list is nonempty. The positive case needs an embedded font and subset evidence. The strict negative case names Helvetica and its status. The exception case proves policy changes behavior. The subprocess case establishes the contract CI actually consumes.
For a production suite, add approved fixtures for CJK Type 0 fonts, Type 3 glyphs, nested forms, encrypted documents, malformed PDFs, several fonts on one page, and duplicate fonts across pages. Keep the files small and record their provenance plus font licenses. A synthetic Vera file is useful but cannot represent every producer.
If your product is a Selenium workflow, use Selenium downloaded PDF checks for the browser and response layers, then pass the saved path to this Python gate. That separation makes it clear whether a failure came from downloading the wrong file or inspecting the right file.
Verification: Run python -m pytest -q. Expected output is 4 passed. Temporarily change the CLI's final policy-failure return from 1 to 0; the subprocess test must fail. Restore the line and rerun to prove the test can detect the regression.
Step 6: Scan a Directory and Preserve JSON Evidence
Put exported documents under artifacts/, then scan the directory recursively. The command discovers only .pdf files, prints each result, and produces one combined report:
python scripts/pdf_font_check.py artifacts \
--report reports/pdf-fonts.json
Inspect the summary without adding another Python dependency:
python -c "import json; r=json.load(open('reports/pdf-fonts.json')); print(r['passed'], len(r['documents']))"
The report preserves document paths, policy mode, font count, failure count, PDF object IDs, pages, and resource locations. An object ID is particularly useful when Poppler and pikepdf disagree or a producer team needs to inspect raw PDF structure. Locations such as Page 2/Form /Fm0/Font /F3 explain why a visually small logo, chart, template fragment, or watermarked form introduced the dependency.
Decide what an empty-font PDF means for your product. An image-only scan can legitimately contain zero font resources and therefore pass this narrow embedding gate. It may still fail OCR, accessibility, or searchable-text requirements. Add a separate rule if at least one text font is expected. Do not make font_count == 0 a universal embedding failure because diagrams and photo-only documents can be valid outputs.
Also keep sensitive paths and content out of the report. This implementation records structural names, not extracted text or font bytes. If filenames contain customer identifiers, normalize artifact names before uploading evidence. Apply the same access and retention controls used for the PDFs themselves.
Combine this structural report with a visual check when font metrics, line breaks, or clipping matter. The visual regression in CI tutorial covers stable rendering baselines. Font embedding says that a program exists; image comparison says that selected pages render as reviewed. Neither substitutes for semantic value assertions.
Verification: Copy both sample PDFs into artifacts/ and run the directory command. It must exit 1, print one PASS: and one FAIL:, and create valid JSON. Run python -m json.tool reports/pdf-fonts.json > /dev/null; it must exit 0.
Step 7: validate pdf font embedding automatically in GitHub Actions
Create .github/workflows/pdf-font-gate.yml. In a real application, replace the sample generation command with the export build that creates artifacts/*.pdf. Keep the fixture tests because they prove the gate's own pass and fail behavior independently of product output.
name: PDF font gate
on:
pull_request:
workflow_dispatch:
jobs:
validate-fonts:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.14.5'
cache: pip
- name: Install pinned Python dependencies
run: python -m pip install --requirement requirements.txt
- name: Test the font validator
run: python -m pytest -q
- name: Build PDF artifacts
run: python scripts/make_font_samples.py
- name: Stage the releasable PDF
run: |
mkdir -p artifacts
cp samples/embedded-font.pdf artifacts/release.pdf
- name: Validate PDF font embedding
run: |
python scripts/pdf_font_check.py artifacts \
--report reports/pdf-fonts.json
- name: Upload font evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: pdf-font-report
path: reports/pdf-fonts.json
if-no-files-found: error
retention-days: 14
The validation step naturally fails the job on exit code 1 or 2. Do not append || true, because that converts the release control into a logging step. if: always() still uploads the report after a policy failure. An input error also attempts report creation, although failures before the Python process starts, such as missing dependencies, can leave no report and correctly make artifact upload complain.
Pin action references according to your supply-chain policy. Version tags are readable, while immutable commit SHAs give stronger change control. If PDFs contain private data, do not upload the documents by default. Retain the structural report and expose the original file only through an approved restricted diagnostic workflow. The broader GitHub Actions for Playwright guide is useful when the export originates in a browser test and needs caching, server startup, or trace retention.
Verification: Push a branch with embedded-font.pdf staged as release.pdf; the workflow must pass and the artifact must contain "passed": true. Change the copy source to unembedded-base14.pdf; the validation step must fail and the uploaded report must name Helvetica with status not_embedded. Restore the good source after proving the negative path.
Troubleshooting
Problem: the PDF looks correct, but Helvetica is reported as not embedded -> The local viewer is substituting a system font or relying on historical Base 14 behavior. Confirm emb no with pdffonts, then configure the producer to register and use an embeddable font. Use --allow-base14 only when the documented consumer policy intentionally accepts substitution.
Problem: a Type 0 font fails even though the parent dictionary has no /FontDescriptor -> Inspect /DescendantFonts. Composite Type 0 parents normally store encoding and descendant references, while a descendant CID font holds the descriptor and font stream. Keep the descendant resolution in assess_font() and add the failing CJK file as a licensed regression fixture.
Problem: the report names a font that is not visible on the page -> Follow the locations path. A nested form, watermark, chart, or unused resource dictionary may declare it. Compare the object ID with pdffonts, inspect the producer template, and remove the stale resource rather than ignoring every font with that name.
Problem: pikepdf.Pdf.open() rejects an encrypted file -> Decide whether the delivery contract permits encryption and where the password comes from. Never hard-code it in source or print it in CI. Decrypt a controlled copy through an approved secret path before validation, or fail closed if consumers are expected to open the file without credentials.
Problem: an embedded subset passes but later editing shows missing characters -> The original subset contains only glyphs used when the PDF was produced. Require full embedding for editable templates, or regenerate the document from its source whenever text changes. Do not treat a read-only delivery policy as an authoring policy.
Problem: Poppler and the Python report disagree -> Compare the file hash first to prove both tools opened identical bytes, then compare object IDs and nested form locations. Record tool versions. Reduce the PDF to a licensed minimal fixture and investigate the dictionary shape before weakening the rule.
Where To Go Next
Add the gate immediately after your application's export step, retain its JSON evidence, and seed your regression set with each real producer defect. If the document travels as an email attachment, combine it with testing optional PDF email attachments so attachment presence, media type, filename, and font portability are covered at the correct layers.
Font embedding is only one part of document quality. Add page rendering for substitution, clipping, and layout drift. Add semantic checks for account IDs, totals, dates, and authorization. Add accessibility validation when documents must work with assistive technology; accessibility checks in CI provides the pipeline pattern. For archival delivery, run a dedicated PDF/A validator because this script does not claim conformance.
Finally, decide whether declared-resource validation is the right strictness. It gives deterministic, conservative evidence and catches template residue. If file size or producer behavior makes unused resources common, fix the generator or build a separately reviewed content-stream usage analyzer. Do not quietly change the meaning of passed after teams start relying on the gate.
Interview Questions and Answers
Q: How do you determine whether a PDF font is embedded?
For simple Type 1 or TrueType fonts, I resolve the /FontDescriptor and look for /FontFile, /FontFile2, or /FontFile3. For Type 0 fonts, I inspect the descendant CID fonts because that is where the descriptor normally resides. I treat Type 3 separately because its glyph programs are stored in /CharProcs.
Q: Why is opening a PDF successfully not proof of font embedding?
A viewer can substitute a locally installed font when the document lacks its program. That makes the PDF appear healthy on the creator's machine while line breaks, glyph shapes, or missing characters differ elsewhere. I verify the PDF objects and reproduce rendering in a controlled environment.
Q: Does an embedded subset count as embedded?
Yes for immutable delivery, because the used glyph programs are inside the file. I still report it as a subset, since editing the PDF later may require glyphs that were not included. An editable-document policy may require full embedding even when a delivery policy accepts subsets.
Q: What do the emb, sub, and uni columns in pdffonts mean?
emb says a font program is embedded, sub says the embedded program is a subset, and uni says an explicit ToUnicode map exists. Unicode mapping supports text interpretation, but it is not evidence that font bytes are present. I evaluate the columns as different quality signals.
Q: How should a CI font validator fail?
I use 0 for a policy pass, 1 for a readable document that violates embedding policy, and 2 for missing input or parsing failure. The job preserves a JSON report even on policy failure. That separates a product defect from an infrastructure or unreadable-file error.
Q: What other tests are needed after all fonts are embedded?
I render representative pages to detect clipping and changed metrics, assert stable business values, check Unicode extraction where search matters, and run accessibility or PDF/A validators when required. Embedding proves the font program is packaged, not that every glyph maps, renders, or conforms correctly.
Best Practices
- Fail closed on unreadable input and distinguish that condition from a font-policy failure.
- Keep Base 14 allowances visible in the command and report, never buried in an undocumented name list.
- Preserve object IDs, pages, and nested resource locations so producer teams can diagnose the exact dependency.
- Test both a known-good embedded font and a known-bad unembedded font on every validator change.
- Pin parser and fixture-generator versions, then review upgrades against the regression corpus.
- Store only necessary structural evidence when PDFs or filenames contain private information.
- Validate actual release bytes after all merge, watermark, signing, and optimization steps because post-processing can add font resources.
- Pair structural embedding checks with visual, semantic, accessibility, and conformance checks according to product risk.
- Review font licenses before forcing full embedding; technical capability does not grant redistribution rights.
Conclusion
To validate PDF font embedding automatically, inspect every relevant font dictionary, resolve Type 0 descendants, recurse into Form XObjects, and require the correct embedded program or self-contained Type 3 glyph data. Make exceptions explicit, return actionable exit codes, and save JSON evidence that identifies the page, resource path, and object.
Start by running the two generated fixtures locally. Then place the validator after the final PDF transformation in CI and prove both the green and red workflow paths. Once that gate is stable, add rendering and document-specific assertions so portable fonts become one verified part of a complete PDF quality strategy.
Interview Questions and Answers
How would you automate PDF font embedding validation?
I would scan each page's resolved resources, recurse through Form XObjects, and classify every font dictionary. Simple fonts need a descriptor with a FontFile stream, Type 0 fonts require descendant inspection, and Type 3 fonts require CharProcs. The tool would emit JSON and return a policy-specific nonzero exit code in CI.
What is the difference between a fully embedded font and an embedded subset?
A fully embedded font contains its complete program, subject to format and producer behavior. A subset contains only glyphs selected for the current document and usually has a six-letter prefix in its PDF font name. Both are embedded, but subsets are less suitable for later editing.
Why do Type 0 fonts need different validation logic?
A Type 0 dictionary is a composite font wrapper that defines encoding and references descendant CID fonts. The font descriptor and embedded program normally belong to the descendant, not the wrapper. Checking only the parent would create false failures.
How do you prevent a PDF font gate from producing false confidence?
I state its narrow oracle and test both outcomes with controlled fixtures. I pair it with rendering, semantic content, Unicode mapping, accessibility, and conformance checks where required. I also run the gate on final delivery bytes rather than an intermediate file.
What evidence should a failed font embedding check provide?
It should provide the file, font name, subtype, subset state, status, PDF object ID, page numbers, and resource locations including nested forms. That lets a producer owner find whether the dependency came from body text, a watermark, a chart, or template residue.
Would you allow the PDF Base 14 fonts without embedding?
Not by default. Viewer support and substitution do not meet a strict portability or archival requirement. I would allow them only for a named consumer contract, make the switch explicit, and record the exception in the machine-readable result.
Frequently Asked Questions
How can I check if all fonts are embedded in a PDF?
Run `pdffonts file.pdf` and inspect the `emb` column, or programmatically resolve each font descriptor and check for `/FontFile`, `/FontFile2`, or `/FontFile3`. Composite Type 0 fonts require inspecting their descendants, while Type 3 fonts use `/CharProcs`.
Can I validate PDF font embedding automatically in CI?
Yes. Run a deterministic scanner after the final PDF build, return a nonzero code for unembedded fonts, and preserve a JSON report as CI evidence. Include known-good and known-bad fixtures so the gate itself is tested.
Are subset fonts considered embedded?
Yes, a subset includes the font program data needed for the glyphs used in the document. Subsets are normally suitable for read-only delivery but can be insufficient if someone later edits the PDF and adds new characters.
Why does my PDF display correctly when fonts are not embedded?
Your viewer is probably substituting a locally available font or applying Base 14 compatibility behavior. Another operating system, viewer, or print service may choose different metrics or lack required glyphs, so local appearance is not portable evidence.
Should Helvetica in a PDF fail an embedding check?
A strict portable or archival policy should fail unembedded Helvetica. Some workflows intentionally allow the historical Base 14 fonts, but that exception should be explicit, documented, and visible in the validation report.
Does font embedding guarantee PDF/A compliance?
No. Font embedding is one PDF/A concern, but conformance also covers metadata, color, transparency, encryption, and other rules depending on the selected part and level. Use a dedicated PDF/A validator for a conformance claim.
When should the font check run in a PDF pipeline?
Run it on the final bytes after merging, watermarking, optimization, signing, and any other post-processing. An earlier artifact can pass while a later transformation introduces a new unembedded font resource.