LaTeX & Typst (papers, theses, resumes and slides) Module 8 – Theses, reports and long documents
Archival PDF/A and final submission checks
Export PDF/A-2b or 2u from LaTeX and Typst, validate it with veraPDF, and check fonts, metadata, page size and file size before you submit.
What you will learn
- Export PDF/A-2b and PDF/A-2u files from LaTeX and from Typst
- Validate a file with veraPDF and read the rules it reports as failed
- Check fonts, metadata, page size and file size before you submit a thesis
- Avoid the rewriting tools that silently break conformance after export
Before you start
What you’ll learn · Before you start
What you will learn
- Export PDF/A-2b and PDF/A-2u files from LaTeX and from Typst
- Validate a file with veraPDF and read the rules it reports as failed
- Check fonts, metadata, page size and file size before you submit a thesis
- Avoid the rewriting tools that silently break conformance after export
Before you start
On this page
A library that keeps theses may ask for PDF/A, and a portal that receives them may check the page size, the fonts and the file size too. In this lesson’s run, a first try on the report class’s own settings failed 6 of 7 submission checks, and a copy compressed after export lost its PDF/A identification while the compressor printed nothing.
Here you export a thesis as PDF/A-2b and PDF/A-2u from LaTeX and from Typst, validate each file with veraPDF and read its report, see a figure break conformance, run a submission check of your own, and measure which tools that rewrite a PDF keep it archival. Every run was recorded with TeX Live 2026, Typst 0.15.1 and veraPDF 1.30.
PDF/A is a PDF that carries everything it needs
PDF/A: The ISO 19005 family of standards for PDFs meant to be kept, whose files must carry what they need to show the same pages later, such as their fonts, and name their part and level in their metadata. is the ISO 19005 family of standards for PDFs that are meant to be kept: the file must hold what it needs to show the same pages later, its fonts among them, and say in its XMP metadata: The block of XML inside a PDF that describes the file, such as its title, its author and, in a PDF/A file, the part and conformance level it claims. pdfinfo -meta prints it. which part and level of the standard it follows. veraPDF, the validator this lesson uses, describes itself as designed for the needs of digital preservationists. The part sets the PDF version and what is allowed, and the conformance level: The letter after a PDF/A part, as in PDF/A-2b or 2u: b for the basic rules, u for text that maps to Unicode as well, a for an accessible tagged file as well. how much more the file promises. Typst’s documentation of its PDF export sums them up:
| Part or level | What it adds, as Typst’s PDF documentation puts it |
|---|---|
| PDF/A-1 | based on PDF 1.4; transparency is not allowed |
| PDF/A-2 | based on PDF 1.7 |
| PDF/A-3 | as part 2, with attachments of any kind |
| PDF/A-4 | based on PDF 2.0; level f allows attachments |
| level b, basic | the file follows the part’s rules |
| level u | also, all text consists of known Unicode code points |
| level a | also, the file is an accessible tagged PDF; PDF/A-2a also forbids private-use characters |
Read your institution’s instructions for the part and level it accepts; this lesson exports 2b and 2u, two levels of part 2. Level u matters for a thesis that people search and copy from, because every character must map to Unicode, and level a needs a tagged PDF, the subject of this track’s accessibility lessons.
Checkpoint
Check: PDF/A parts and levels
Ask for the standard when you export
LaTeX’s tagging project documents \DocumentMetadata, written before \documentclass, as the way to set a
document’s metadata, the standards it should comply with among them. In TeX Live 2026 its pdfstandard key accepts
A-1B, A-2A, A-2B, A-2U, A-3A, A-3B, A-3U, A-4, A-4F and A-4E, as its PDF management code lists them. Typst takes the
standard on the command line: its PDF documentation lists --pdf-standard with values from a-1b to a-4e and
ua-1. The same short thesis, in both:
pdfa/thesis.tex
% thesis.tex: a short thesis exported as PDF/A-2u. \DocumentMetadata must come before \documentclass; it loads
% LaTeX's PDF management code, which writes the XMP metadata, the PDF/A identification and a colour profile.
\DocumentMetadata{lang=en-IN, pdfstandard=a-2u}
\documentclass[a4paper,12pt]{report}
\usepackage{graphicx}
\usepackage{hyperref}
\hypersetup{pdftitle={Rooftop Rainwater Harvesting in Example District}, pdfauthor={Nisha Varghese}}
\begin{document}
\chapter{Introduction}
Rooftop rainwater harvesting stores the rain that falls on a roof for later use. This thesis measures how much one
roof in Example District can collect, and in which months.
\chapter{Results}
The library gauge recorded its wettest month in August, 701 mm.
% FIGURE
\end{document} pdfa/thesis.typ
// thesis.typ: the same short thesis in Typst. The PDF standard is chosen when it is compiled:
// typst compile --pdf-standard a-2u thesis.typ
#set document(title: [Rooftop Rainwater Harvesting in Example District], author: "Nisha Varghese")
#set text(lang: "en", region: "IN", size: 12pt)
#set page(paper: "a4", numbering: "1")
#set heading(numbering: "1.1")
#show heading.where(level: 1): it => { pagebreak(weak: true); it }
= Introduction
Rooftop rainwater harvesting stores the rain that falls on a roof for later use. This thesis measures how much one roof
in Example District can collect, and in which months.
= Results
The library gauge recorded its wettest month in August, 701 mm.
// FIGURE from session import files, run
# pdfa/thesis.tex exported as PDF/A-2u and, in a copy, as PDF/A-2b; pdfa/thesis.typ compiled with each standard; then
# vera.py validates every file, first against the 2u profile and then the two 2b files against 2b.
files("vera.py", "pdfa/thesis.tex=thesis.tex", "pdfa/thesis.typ=thesis.typ")
run(r"""
latexmk -pdf -interaction=nonstopmode -silent thesis.tex > /dev/null 2>&1
sed 's/pdfstandard=a-2u/pdfstandard=a-2b/' thesis.tex > thesis-2b.tex
latexmk -pdf -interaction=nonstopmode -silent thesis-2b.tex > /dev/null 2>&1
typst compile --pdf-standard a-2u thesis.typ typst-2u.pdf
typst compile --pdf-standard a-2b thesis.typ typst-2b.pdf
pdfinfo -meta thesis.pdf | grep -E "pdfaid:(part|conformance)"
python3 vera.py 2u thesis.pdf typst-2u.pdf thesis-2b.pdf typst-2b.pdf
python3 vera.py 2b thesis-2b.pdf typst-2b.pdf
""") Output
$ latexmk -pdf -interaction=nonstopmode -silent thesis.tex > /dev/null 2>&1 $ sed 's/pdfstandard=a-2u/pdfstandard=a-2b/' thesis.tex > thesis-2b.tex $ latexmk -pdf -interaction=nonstopmode -silent thesis-2b.tex > /dev/null 2>&1 $ typst compile --pdf-standard a-2u thesis.typ typst-2u.pdf $ typst compile --pdf-standard a-2b thesis.typ typst-2b.pdf $ pdfinfo -meta thesis.pdf | grep -E "pdfaid:(part|conformance)" <pdfaid:part>2</pdfaid:part> <pdfaid:conformance>U</pdfaid:conformance> $ python3 vera.py 2u thesis.pdf typst-2u.pdf thesis-2b.pdf typst-2b.pdf thesis.pdf: PDF/A-2u validation profile: compliant, 146 rules passed, 0 failed typst-2u.pdf: PDF/A-2u validation profile: compliant, 146 rules passed, 0 failed thesis-2b.pdf: PDF/A-2u validation profile: NOT compliant, 145 rules passed, 1 failed 6.6.4-3 (1 check): The "conformance" property of the PDF/A Identification Schema is B instead of "U" for PDF/A-2u conforming file typst-2b.pdf: PDF/A-2u validation profile: NOT compliant, 145 rules passed, 1 failed 6.6.4-3 (1 check): The "conformance" property of the PDF/A Identification Schema is B instead of "U" for PDF/A-2u conforming file $ python3 vera.py 2b thesis-2b.pdf typst-2b.pdf thesis-2b.pdf: PDF/A-2b validation profile: compliant, 144 rules passed, 0 failed typst-2b.pdf: PDF/A-2b validation profile: compliant, 144 rules passed, 0 failed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 export_session.py
pdfinfo -meta read the claim back from the XMP metadata, part 2 and conformance U. All four files then went
through veraPDF. The two 2u files passed the 146 rules of the PDF/A-2u profile; the two 2b files failed it on one
rule, because they claim conformance B, and passed all 144 rules of the PDF/A-2b profile, the level they claim. A file
is not “PDF/A” in general: it conforms, or not, to the part and level it states.
pdfx or hyperxmp in a new document
Wrong: loading pdfx or hyperxmp to make PDF/A in a document that starts with \DocumentMetadata. The tagging
status list marks both as not supported with the new PDF management: pdfx’s loading is suppressed, and the list says to set standards with
\DocumentMetadata keys. Right: \DocumentMetadata{lang=en-IN, pdfstandard=a-2u} above \documentclass.
Checkpoint
Check: Exporting PDF/A from LaTeX and Typst
veraPDF reads the file against one profile at a time
veraPDF’s command line validates a file against a built-in validation profile: The set of rules that veraPDF checks a file against, one profile for each part and level of PDF/A or PDF/UA, such as the 146 rules of PDF/A-2u.
chosen with --flavour, such as 2u, or, with --flavour 0, the profile named by the file’s own metadata, and
--format json prints a report that a program can read. vera.py prints the part of that report that matters: the
profile, the verdict, and each failed rule with its clause of the standard and veraPDF’s message. Step through the
check of the 2b file against PDF/A-2u:
"""Validates PDFs with veraPDF against one PDF/A or PDF/UA profile and prints a short report of each: the profile, the
verdict, how many of the profile's rules passed and failed, and every failed rule with its clause, its test number,
the number of failed checks and veraPDF's message.
python3 vera.py 2u thesis.pdf other.pdf
The flavour is veraPDF's name for the profile (2b, 2u, 2a, ua1 …). veraPDF runs once for all the files; its JSON
report is read here because the report also holds times and build dates, which change on every run and are left out,
and the failed rules are printed in the order of the standard's clauses.
"""
import json
import subprocess
import sys
def main(flavour, pdfs):
done = subprocess.run(["verapdf", "--format", "json", "--flavour", flavour, *pdfs], capture_output=True, text=True)
report = json.loads(done.stdout)["report"]
versions = {item["id"]: item["version"] for item in report["buildInformation"]["releaseDetails"]}
if not versions.get("core", "").startswith("1.30."):
sys.exit(f"this report needs veraPDF 1.30; found {versions}")
for job in report["jobs"]:
name = job["itemDetails"]["name"].rsplit("/", 1)[-1]
results = job["validationResult"]
result = results[0] if isinstance(results, list) else results
details = result["details"]
verdict = "compliant" if result["compliant"] else "NOT compliant"
print(f"{name}: {result['profileName']}: {verdict}, "
f"{details['passedRules']} rules passed, {details['failedRules']} failed")
# veraPDF lists the failed rules in no fixed order, so they are sorted by clause and test number.
for rule in sorted(details["ruleSummaries"], key=lambda r: ([int(n) for n in r["clause"].split(".")], r["testNumber"])):
message = rule["checks"][0].get("errorMessage") if rule["checks"] else None
checks = f"{rule['failedChecks']} check" + ("s" if rule["failedChecks"] != 1 else "")
print(f" {rule['clause']}-{rule['testNumber']} ({checks}): {message or rule['description']}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2:]) The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The file's claim. The PDF/A identification sits in the file's XMP metadata, part 2 and conformance B, written by LaTeX's PDF management code when \DocumentMetadata asked for a-2b.
- The profile is chosen. vera.py ran veraPDF with --flavour 2u, the PDF/A-2u profile of 146 rules. With --flavour 0, veraPDF would take the profile from the file's own claim instead.
- Every object is checked. Each rule is a test on the objects it concerns, such as fonts, colour spaces, streams and metadata, and each object it fails is one failed check.
- One rule fails. Rule 6.6.4-3 of ISO 19005-2:2011 failed once: a level U file must say conformance U, and this one says B. The other 145 rules passed.
- Read the message. The report names the clause, the test and the reason, which points at the fix, the pdfstandard key of \DocumentMetadata, not at the fonts or the pictures.
- The same file, its own profile. Against the PDF/A-2b profile, the level it claims, the same file passed all 144 rules. A validator answers one question at a time, so ask the portal's question.
The report tells you where to look, and the next session shows why that matters. Its figure, chart.pdf, uses
Helvetica without embedding it, as some plotting programs and old exports do:
from session import files, read, run, shell, write
# The thesis with one more figure: chart.pdf, made by make_chart.py, which uses Helvetica without embedding it.
# pdflatex builds it as PDF/A-2u, and veraPDF judges the result.
files("vera.py", "make_chart.py", "pdfa/thesis.tex=thesis.tex")
shell("python3 make_chart.py chart.pdf")
write("with-chart.tex", read("thesis.tex").replace("% FIGURE", r"\includegraphics{chart.pdf}"))
run(r"""
pdffonts chart.pdf
latexmk -pdf -interaction=nonstopmode -silent with-chart.tex > /dev/null 2>&1
python3 vera.py 2u with-chart.pdf
""") Output
$ pdffonts chart.pdf name type encoding emb sub uni object ID ------------------------------------ ----------------- ---------------- --- --- --- --------- Helvetica Type 1 WinAnsi no no no 5 0 $ latexmk -pdf -interaction=nonstopmode -silent with-chart.tex > /dev/null 2>&1 $ python3 vera.py 2u with-chart.pdf with-chart.pdf: PDF/A-2u validation profile: NOT compliant, 145 rules passed, 1 failed 6.2.11.4.1-1 (1 check): The font program is not embedded
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 failures_session.py
pdflatex built the thesis without a warning, and veraPDF found rule 6.2.11.4.1 broken: the font program is not embedded. Typst refuses such a file at export instead:
from session import files, read, run, shell, write
# The same figure in the Typst thesis, exported as PDF/A-2u, and the thesis without a date, exported as PDF/A-2u and
# as PDF/A-2b: Typst checks these rules while it exports, and stops.
files("make_chart.py", "pdfa/thesis.typ=thesis.typ")
shell("python3 make_chart.py chart.pdf")
write("with-chart.typ", read("thesis.typ").replace("// FIGURE", '#image("chart.pdf", alt: "Rainfall in mm")'))
write("nodate.typ", "#set document(date: none)\n" + read("thesis.typ"))
run(r"""
!typst compile --pdf-standard a-2u with-chart.typ
!typst compile --pdf-standard a-2u nodate.typ
!typst compile --pdf-standard a-2b nodate.typ
""") Output
$ typst compile --pdf-standard a-2u with-chart.typ
error: embedding PDFs is currently not supported in this export mode
┌─ with-chart.typ:15:1
│
15 │ #image("chart.pdf", alt: "Rainfall in mm")
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
│
= hint: try converting the PDF to an SVG before embedding it
[exit status 1]
$ typst compile --pdf-standard a-2u nodate.typ
error: PDF/A-2u error: missing document date
= hint: set the date of the document
[exit status 1]
$ typst compile --pdf-standard a-2b nodate.typ
error: PDF/A-2b error: missing document date
= hint: set the date of the document
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 refusals_session.py
Embedding PDFs is not supported in this export mode, Typst said, with a hint to convert the PDF to SVG. It also
refused a document whose date was none, with missing document date, for both levels.
PDF images in a Typst PDF/A export
Typst 0.15.1 reports a PDF image in a PDF/A export as currently not supported, so a later version may accept it. After an upgrade, try the PDF figure again before you convert it.
The fixes change the figure, not the thesis:
from session import files, read, run, shell, write
# The same figure fixed before it goes into the thesis: Ghostscript writes it again with its font embedded, for LaTeX,
# and pdftocairo turns it into SVG, for Typst. Both theses are then exported as PDF/A-2u and validated.
files("vera.py", "make_chart.py", "pdfa/thesis.tex=thesis.tex", "pdfa/thesis.typ=thesis.typ")
shell("python3 make_chart.py chart.pdf")
write("fixed-latex.tex", read("thesis.tex").replace("% FIGURE", r"\includegraphics{chart-embedded.pdf}"))
write("fixed-typst.typ", read("thesis.typ").replace("// FIGURE", '#image("chart.svg", alt: "Rainfall in mm")'))
run(r"""
gs -q -sDEVICE=pdfwrite -dEmbedAllFonts=true -o chart-embedded.pdf -c "<</NeverEmbed []>> setdistillerparams" -f chart.pdf
pdffonts chart-embedded.pdf
latexmk -pdf -interaction=nonstopmode -silent fixed-latex.tex > /dev/null 2>&1
pdftocairo -svg chart.pdf chart.svg
typst compile --pdf-standard a-2u fixed-typst.typ
python3 vera.py 2u fixed-latex.pdf fixed-typst.pdf
""") Output
$ gs -q -sDEVICE=pdfwrite -dEmbedAllFonts=true -o chart-embedded.pdf -c "<</NeverEmbed []>> setdistillerparams" -f chart.pdf $ pdffonts chart-embedded.pdf name type encoding emb sub uni object ID ------------------------------------ ----------------- ---------------- --- --- --- --------- XBCPUG+Helvetica Type 1C WinAnsi yes yes no 7 0 $ latexmk -pdf -interaction=nonstopmode -silent fixed-latex.tex > /dev/null 2>&1 $ pdftocairo -svg chart.pdf chart.svg $ typst compile --pdf-standard a-2u fixed-typst.typ $ python3 vera.py 2u fixed-latex.pdf fixed-typst.pdf fixed-latex.pdf: PDF/A-2u validation profile: compliant, 146 rules passed, 0 failed fixed-typst.pdf: PDF/A-2u validation profile: compliant, 146 rules passed, 0 failed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fixes_session.py
Ghostscript wrote the figure again, as chart-embedded.pdf, with the font embedded; its documentation says its
default settings never embed the 14 standard fonts, Helvetica among them, and that
<< /NeverEmbed [ ] >> setdistillerparams overrides that.
pdftocairo turned the figure into an SVG for Typst. Then both theses passed all 146 rules.
Check the figures first
pdffonts figure.pdf before a figure goes into a thesis: every row should say yes under emb. A figure that fails is
cheaper to fix on its own than a thesis is after export.
Checkpoint
Check: Validating with veraPDF
The portal checks more than PDF/A
PDF/A is one of several things a submission portal may check. submit_check.py makes seven checks of its own: A4 on
every page, every font embedded, a title and an author in the PDF’s metadata, the size against a limit, the PDF/A
claim in the XMP metadata, and veraPDF’s verdict. Its limit of 10 MB is an example; use your portal’s. It checks a
first try, the thesis on the report class’s own settings with the figure as it came, and the final thesis:
"""Checks a thesis PDF the way a submission portal might before it accepts the file, and prints one line per check:
what it found, and PASS or FAIL.
python3 submit_check.py thesis.pdf --max-mb 10 --standard 2u
The checks are the page size (A4, 210 by 297 mm, within 1 mm, on every page), that every font is embedded (pdffonts'
emb column), that the PDF's metadata gives a title and an author (pdfinfo), that the file is no larger than the limit,
that its XMP metadata claims the PDF/A part and level asked for (pdfinfo -meta), and that veraPDF finds it compliant
with that profile. The limit and the standard are the portal's; the ones above are an example.
"""
import json
import os
import re
import subprocess
import sys
MM = 25.4 / 72
def output(*command):
return subprocess.run(command, capture_output=True, text=True).stdout
def checks(pdf, max_mb, standard):
info = output("pdfinfo", pdf)
fields = dict(re.findall(r"^([A-Za-z ]+?):[ \t]*(.*)$", info, re.M))
pages = int(fields["Pages"])
sizes = re.findall(r"size:\s+([\d.]+) x ([\d.]+) pts", output("pdfinfo", "-f", "1", "-l", str(pages), pdf))
mm = {(round(float(w) * MM, 1), round(float(h) * MM, 1)) for w, h in sizes}
a4 = bool(mm) and all(abs(w - 210) <= 1 and abs(h - 297) <= 1 for w, h in mm)
yield "page size", ", ".join(f"{w} × {h} mm" for w, h in sorted(mm)) + f" on {pages} pages", a4
rows = output("pdffonts", pdf).splitlines()[2:]
embedded = sum(1 for row in rows if row.split()[-5] == "yes")
yield "fonts embedded", f"{embedded} of {len(rows)}", embedded == len(rows)
for field in ("Title", "Author"):
value = fields.get(field, "").strip()
shown = value if len(value) <= 36 else value[:35] + "…"
yield field.lower(), shown or "(none)", bool(value)
size_mb = os.path.getsize(pdf) / 1_000_000
yield "file size", f"{size_mb:.2f} MB, limit {max_mb:g} MB", size_mb <= max_mb
meta = output("pdfinfo", "-meta", pdf)
part = re.search(r"<pdfaid:part>(\d)</pdfaid:part>", meta)
level = re.search(r"<pdfaid:conformance>([ABU])</pdfaid:conformance>", meta)
claim = f"PDF/A-{part[1]}{level[1].lower()}" if part and level else "none"
yield "PDF/A claim", claim, claim == f"PDF/A-{standard}"
report = json.loads(output("verapdf", "--format", "json", "--flavour", standard, pdf))["report"]
core = next(item["version"] for item in report["buildInformation"]["releaseDetails"] if item["id"] == "core")
if not core.startswith("1.30."):
sys.exit(f"these checks need veraPDF 1.30; found {core}")
result = report["jobs"][0]["validationResult"]
result = result[0] if isinstance(result, list) else result
failed = result["details"]["failedRules"]
yield "veraPDF", f"PDF/A-{standard}: " + ("compliant" if result["compliant"] else f"{failed} rules failed"), \
result["compliant"]
def main(pdf, max_mb, standard):
print(f"{pdf}:")
print(f" {'check':<16}{'found':<40}result")
passed = total = 0
for name, found, ok in checks(pdf, max_mb, standard):
total += 1
passed += ok
print(f" {name:<16}{found:<40}{'PASS' if ok else 'FAIL'}")
print(f" {passed} of {total} checks pass")
if __name__ == "__main__":
arguments = sys.argv[1:]
main(arguments[0], float(arguments[arguments.index("--max-mb") + 1]), arguments[arguments.index("--standard") + 1]) from session import files, read, run, shell, write
# Two PDFs through the same submission checks: a first try, the thesis on the report class's own settings with the
# chart as it came, and the final thesis, PDF/A-2u with the chart whose font Ghostscript embedded.
files("vera.py", "make_chart.py", "submit_check.py", "pdfa/thesis.tex=thesis.tex")
shell("python3 make_chart.py chart.pdf")
shell('gs -q -sDEVICE=pdfwrite -dEmbedAllFonts=true -o chart-embedded.pdf -c "<</NeverEmbed []>> setdistillerparams" '
'-f chart.pdf')
final = read("thesis.tex").replace("% FIGURE", r"\includegraphics{chart-embedded.pdf}")
first_try = (final.replace("\\DocumentMetadata{lang=en-IN, pdfstandard=a-2u}\n", "")
.replace("[a4paper,12pt]{report}", "{report}")
.replace("\\hypersetup{pdftitle={Rooftop Rainwater Harvesting in Example District}, pdfauthor={Nisha Varghese}}\n", "")
.replace("chart-embedded.pdf", "chart.pdf"))
write("first-try.tex", first_try)
write("final.tex", final)
run(r"""
latexmk -pdf -interaction=nonstopmode -silent first-try.tex > /dev/null 2>&1
latexmk -pdf -interaction=nonstopmode -silent final.tex > /dev/null 2>&1
python3 submit_check.py first-try.pdf --max-mb 10 --standard 2u
python3 submit_check.py final.pdf --max-mb 10 --standard 2u
""") Output
$ latexmk -pdf -interaction=nonstopmode -silent first-try.tex > /dev/null 2>&1 $ latexmk -pdf -interaction=nonstopmode -silent final.tex > /dev/null 2>&1 $ python3 submit_check.py first-try.pdf --max-mb 10 --standard 2u first-try.pdf: check found result page size 215.9 × 279.4 mm on 2 pages FAIL fonts embedded 2 of 3 FAIL title (none) FAIL author (none) FAIL file size 0.03 MB, limit 10 MB PASS PDF/A claim none FAIL veraPDF PDF/A-2u: 5 rules failed FAIL 1 of 7 checks pass $ python3 submit_check.py final.pdf --max-mb 10 --standard 2u final.pdf: check found result page size 210.0 × 297.0 mm on 2 pages PASS fonts embedded 3 of 3 PASS title Rooftop Rainwater Harvesting in Exa… PASS author Nisha Varghese PASS file size 0.05 MB, limit 10 MB PASS PDF/A claim PDF/A-2u PASS veraPDF PDF/A-2u: compliant PASS 7 of 7 checks pass
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 submit_session.py
The first try passed only its file size. Its pages were US letter, 215.9 by 279.4 mm, because the report class’s
default paper is letter; one of its three fonts was not embedded; it had no title and no author in its metadata, so a
library catalogue or a viewer’s title bar would show none; and with no \DocumentMetadata it claimed no PDF/A, so
veraPDF found 5 rules of PDF/A-2u broken. The final thesis passed all seven.
Checkpoint
Check: Submission checks beyond PDF/A
Rewriting a PDF after export can undo the work
A portal’s size limit tempts you to compress the PDF after export, and online forms sometimes ask you to “optimise” it. This session passes the PDF/A-2u thesis through four programs that rewrite a PDF and validates each result:
from session import files, run
# pdfa/thesis.tex exported as PDF/A-2u, then passed through four programs that rewrite a PDF after export, as
# compressors, optimisers and cleaners do: Ghostscript with its ebook settings, qpdf linearising without and with its
# --newline-before-endstream option, and MuPDF's mutool clean. vera.py validates every result; wc -c gives the sizes.
files("vera.py", "pdfa/thesis.tex=thesis.tex")
run(r"""
latexmk -pdf -interaction=nonstopmode -silent thesis.tex > /dev/null 2>&1
gs -q -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook -o ebook.pdf thesis.pdf
qpdf --linearize thesis.pdf linearized.pdf
qpdf --linearize --newline-before-endstream thesis.pdf linearized-eol.pdf
mutool clean -gggg thesis.pdf cleaned.pdf
wc -c thesis.pdf ebook.pdf linearized.pdf linearized-eol.pdf cleaned.pdf
python3 vera.py 2u thesis.pdf ebook.pdf linearized.pdf linearized-eol.pdf cleaned.pdf
""") Output
$ latexmk -pdf -interaction=nonstopmode -silent thesis.tex > /dev/null 2>&1 $ gs -q -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook -o ebook.pdf thesis.pdf $ qpdf --linearize thesis.pdf linearized.pdf $ qpdf --linearize --newline-before-endstream thesis.pdf linearized-eol.pdf $ mutool clean -gggg thesis.pdf cleaned.pdf $ wc -c thesis.pdf ebook.pdf linearized.pdf linearized-eol.pdf cleaned.pdf 48302 thesis.pdf 11750 ebook.pdf 49402 linearized.pdf 49411 linearized-eol.pdf 50763 cleaned.pdf 209628 total $ python3 vera.py 2u thesis.pdf ebook.pdf linearized.pdf linearized-eol.pdf cleaned.pdf thesis.pdf: PDF/A-2u validation profile: compliant, 146 rules passed, 0 failed ebook.pdf: PDF/A-2u validation profile: NOT compliant, 143 rules passed, 3 failed 6.1.7.1-2 (2 checks): Extra spacings or missing EOL characters around keywords 'stream' and 'endstream' 6.2.4.3-4 (12 checks): DeviceGray colour space is used without output intent profile 6.6.4-1 (1 check): The document metadata stream doesn't contain PDF/A Identification Schema linearized.pdf: PDF/A-2u validation profile: NOT compliant, 145 rules passed, 1 failed 6.1.7.1-2 (10 checks): Extra spacings or missing EOL characters around keywords 'stream' and 'endstream' linearized-eol.pdf: PDF/A-2u validation profile: compliant, 146 rules passed, 0 failed cleaned.pdf: PDF/A-2u validation profile: compliant, 146 rules passed, 0 failed
This output changes from run to run: Ghostscript writes the time it ran and a new document ID into ebook.pdf, so its size and the total change by a few bytes from run to run; every other line does not
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 postprocess_session.py
| Rewrite | Size in bytes | PDF/A-2u |
|---|---|---|
| the thesis as exported | 48,302 | compliant |
Ghostscript, -dPDFSETTINGS=/ebook |
11,750 | 3 rules failed, the PDF/A identification gone |
qpdf --linearize |
49,402 | 1 rule failed: spacing around stream keywords |
qpdf --linearize --newline-before-endstream |
49,411 | compliant |
mutool clean -gggg |
50,763 | compliant |
Ghostscript made the file four times smaller and, with these settings, wrote a plain PDF: its metadata kept no
PDF/A identification, its grey colours were used without an output intent: A colour profile embedded in a PDF that says how its device colours, such as plain grey or RGB, are meant to look. PDF/A requires one when such colours are used., and its
streams broke rule 6.1.7.1-2. qpdf’s linearisation broke that one rule only. Its manual calls the newline before
endstream a requirement of PDF/A, and with --newline-before-endstream the file stayed compliant; so did MuPDF’s
clean. None of the four commands printed a word. Ghostscript also wrote the time it ran and a new document ID into
its file, so that file’s size changes by a few bytes from one run to the next; the other sizes stay the same. Make
the file small before export, with images at the size and resolution they are printed at, and validate again after
any program touches the PDF.
Key idea
The last program that writes the PDF decides whether it conforms, so shrink the inputs before export and validate after every rewrite.
Checkpoint
Check: Rewriting a PDF after export
Practise: read veraPDF’s report and check a PDF before you submit
Two short functions automate what this lesson read by eye: a summary of a veraPDF report, and four of the submission checks from what pdfinfo and pdffonts print. Both run in the page against sample tests built from this lesson’s files, each with its first hint open:
Code lab · 2 steps
Read the validator, then check the PDF
Summarise a veraPDF JSON report file by file, then list what keeps a PDF from passing the page-size, font, metadata and size checks.
Exercise · Medium · Python
Read a veraPDF report
vera.py turned veraPDF's JSON report into a few readable lines. Write summary(report) in report.py, which does the same for the part of a report that matters here. report is the JSON that verapdf --format json printed, already loaded with json.loads:
report["report"]["jobs"]is a list with one job per file; a job'sitemDetailshas the file'sname, and itsvalidationResultis a list whose first item holdscompliant,profileNameanddetails;details["ruleSummaries"]lists the failed rules, each with itsclause,testNumberandfailedChecks.
Return one string per file, in order: "<file>: compliant", or "<file>: <n> rules failed: <rules>", where the file is the last part of the name, after the last /, and the rules are written clause-test and joined by ", ". veraPDF lists failed rules in no fixed order, so sort them as vera.py does: by the numbers of the clause, compared one by one (so 6.1.7.1 comes before 6.2.4.3), then by the test number. One failed rule is written "1 rule failed".
The lesson's thesis-2b.pdf, checked against PDF/A-2u, gives "thesis-2b.pdf: 1 rule failed: 6.6.4-3".
Starter code · report.py
def summary(report):
"""Return one line per file of a veraPDF JSON report: compliant, or the rules that failed."""
# Replace this line: read every job's result and list its failed rules.
return [] The sample tests · test_report.py
from report import summary
def job(name, compliant, rules):
return {
"itemDetails": {"name": name, "size": 48302},
"validationResult": [{
"compliant": compliant,
"profileName": "PDF/A-2u validation profile",
"details": {"passedRules": 146 - len(rules), "failedRules": len(rules),
"ruleSummaries": [{"clause": c, "testNumber": t, "failedChecks": n} for c, t, n in rules]},
}],
}
def report(*jobs):
return {"report": {"jobs": list(jobs)}}
def test_compliant():
"""says compliant for the lesson's thesis.pdf"""
assert summary(report(job("~/demo/thesis.pdf", True, []))) == ["thesis.pdf: compliant"]
def test_one_rule():
"""names the one rule that thesis-2b.pdf failed against PDF/A-2u"""
assert summary(report(job("~/demo/thesis-2b.pdf", False, [("6.6.4", 3, 1)]))) == [
"thesis-2b.pdf: 1 rule failed: 6.6.4-3"
]
def test_several_rules_sorted():
"""sorts the failed rules of ebook.pdf by clause, whatever order the report gives"""
rules = [("6.6.4", 1, 1), ("6.2.4.3", 4, 12), ("6.1.7.1", 2, 2)]
assert summary(report(job("~/demo/ebook.pdf", False, rules))) == [
"ebook.pdf: 3 rules failed: 6.1.7.1-2, 6.2.4.3-4, 6.6.4-1"
]
def test_clauses_compare_as_numbers():
"""puts clause 6.2.11.4.1 after 6.2.4.3, and test 3 after test 1 of one clause"""
rules = [("6.2.11.4.1", 1, 1), ("6.6.4", 3, 1), ("6.2.4.3", 4, 2), ("6.6.4", 1, 1)]
assert summary(report(job("~/demo/with-chart.pdf", False, rules))) == [
"with-chart.pdf: 4 rules failed: 6.2.4.3-4, 6.2.11.4.1-1, 6.6.4-1, 6.6.4-3"
]
def test_several_files():
"""gives one line per file, in order"""
jobs = [job("~/demo/thesis.pdf", True, []), job("~/demo/linearized.pdf", False, [("6.1.7.1", 2, 10)])]
assert summary(report(*jobs)) == ["thesis.pdf: compliant", "linearized.pdf: 1 rule failed: 6.1.7.1-2"]
def test_plain_file_name():
"""keeps a name without a folder as it is"""
assert summary(report(job("cleaned.pdf", True, []))) == ["cleaned.pdf: compliant"] A hint
Loop over report["report"]["jobs"], take job["validationResult"][0], and build each rule's name with f"{rule['clause']}-{rule['testNumber']}". name.rsplit("/", 1)[-1] keeps the part after the last slash, and sorted(rules, key=lambda r: ([int(n) for n in r["clause"].split(".")], r["testNumber"])) puts the rules in order.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Check a PDF before you submit it
Write problems(pdfinfo, pdffonts, size_bytes, max_mb) in problems.py: four of the lesson's submission checks, read from what the tools print. pdfinfo is the text that pdfinfo -f 1 -l <pages> printed, pdffonts the text of pdffonts, size_bytes the file's size and max_mb the portal's limit in megabytes (1 MB is 1,000,000 bytes).
Return the problems as strings, in this order:
"page <n> is <w> × <h> mm, not A4"for each linePage <n> size: <w> x <h> ptswhose size, in millimetres (25.4 / 72 to the point) and rounded to 0.1, is more than 1 mm away from 210 by 297;"font <name> is not embedded"for each font row whoseembcolumn, the fifth from the end, isno;"no title"and"no author"when theTitle:orAuthor:line is missing or empty;"file is <x> MB, over <max_mb> MB"when the file is larger than the limit, with<x>to two decimals.
The lesson's first try gives ["page 1 is 215.9 × 279.4 mm, not A4", "page 2 is 215.9 × 279.4 mm, not A4", "font Helvetica is not embedded", "no title", "no author"].
Starter code · problems.py
import re
def problems(pdfinfo, pdffonts, size_bytes, max_mb):
"""Return what keeps this PDF from passing the four submission checks."""
# Replace this line: read the page sizes, the fonts, the metadata and the size.
return [] The sample tests · test_problems.py
from problems import problems
FIRST_TRY = """Title:
Subject:
Keywords:
Author:
Creator: LaTeX with hyperref
Producer: pdfTeX-1.40.29
Pages: 2
Encrypted: no
Page 1 size: 612 x 792 pts (letter)
Page 1 rot: 0
Page 2 size: 612 x 792 pts (letter)
Page 2 rot: 0
File size: 30165 bytes
"""
FIRST_TRY_FONTS = """name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
ZRRFJI+CMBX12 Type 1 Builtin yes yes yes 16 0
VNVMRQ+CMR10 Type 1 Builtin yes yes yes 17 0
Helvetica Type 1 WinAnsi no no no 24 0
"""
FINAL = """Title: Rooftop Rainwater Harvesting in Example District
Author: Nisha Varghese
Pages: 2
Page 1 size: 595.276 x 841.89 pts (A4)
Page 2 size: 595.276 x 841.89 pts (A4)
"""
FINAL_FONTS = """name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
QVQPVE+SFBX2488 Type 1 Custom yes yes yes 18 0
FOIFPW+SFRM1200 Type 1 Custom yes yes yes 19 0
XBCPUG+Helvetica Type 1C WinAnsi yes yes no 30 0
"""
def test_the_first_try():
"""reports every problem of the lesson's first try"""
assert problems(FIRST_TRY, FIRST_TRY_FONTS, 30165, 10) == [
"page 1 is 215.9 × 279.4 mm, not A4", "page 2 is 215.9 × 279.4 mm, not A4",
"font Helvetica is not embedded", "no title", "no author",
]
def test_the_final_thesis():
"""finds nothing wrong with the final thesis"""
assert problems(FINAL, FINAL_FONTS, 48302, 10) == []
def test_too_large():
"""reports a file over the limit, to two decimals"""
assert problems(FINAL, FINAL_FONTS, 12_400_000, 10) == ["file is 12.40 MB, over 10 MB"]
def test_empty_title():
"""treats an empty Title line as no title"""
info = FINAL.replace("Title: Rooftop Rainwater Harvesting in Example District", "Title:")
assert problems(info, FINAL_FONTS, 48302, 10) == ["no title"]
def test_one_wrong_page():
"""reports only the page that is not A4, within 1 mm"""
info = FINAL.replace("Page 2 size: 595.276 x 841.89 pts (A4)", "Page 2 size: 841.89 x 595.276 pts (A4)")
assert problems(info, FINAL_FONTS, 48302, 10) == ["page 2 is 297.0 × 210.0 mm, not A4"]
def test_a_millimetre_is_fine():
"""accepts a page within 1 mm of A4"""
info = FINAL.replace("595.276 x 841.89", "596 x 842")
assert problems(info, FINAL_FONTS, 48302, 10) == []
def test_one_side_wrong():
"""reports a page whose width is right but whose height is not"""
info = FINAL.replace("595.276 x 841.89", "595.276 x 793.7")
assert problems(info, FINAL_FONTS, 48302, 10) == [
"page 1 is 210.0 × 280.0 mm, not A4", "page 2 is 210.0 × 280.0 mm, not A4",
] A hint
Regular expressions do most of the reading: re.findall(r"^Page +(\d+) size: +([\d.]+) x ([\d.]+) pts", pdfinfo, re.M) for the sizes, and re.search(r"^Title:[ \t]*(\S.*)$", pdfinfo, re.M) for a title that is not empty. The font rows are the lines after the first two of pdffonts.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Finally, practise the ideas. Each question explains its answer as soon as you check it, and the session returns to what you have not yet got right:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what does PDF/A-2u promise that PDF/A-2b does not, and which should a thesis claim?
Both are PDF/A-2, the part of ISO 19005 based on PDF 1.7, and a file of either level follows that part’s rules: for
example, every font is embedded, device colours come with an output intent, and the XMP metadata names the part and
level. Level u adds one promise:
all the text consists of known Unicode code points, so it can be searched, copied and read aloud correctly. The level
is also a claim in the file’s metadata, and validators check the claim: in this lesson, a LaTeX file exported with
pdfstandard=a-2b passed all 144 rules of the PDF/A-2b profile but failed the PDF/A-2u profile on one rule, because it
said conformance B. For a thesis, export the level the repository asks for, and validate against exactly that level.
Two harder questions follow: a thesis rejected by a repository after it was compressed, and the checks a department should run on every thesis it receives. Decide your first step before you open each one:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A university repository rejects your thesis as "not PDF/A compliant". You exported it as PDF/A-2b from LaTeX, and then compressed it with an online tool to get under the 10 MB limit. Walk through how you find the cause and what you submit instead.
-
Mid to senior
A department receives about 150 theses a year, written in LaTeX, Typst and word processors, and the library wants PDF/A-2u with A4 pages, embedded fonts, a title and an author, and at most 20 MB. Design the checks that run on every submission, and say what a student sees when one fails.
Key takeaways
- PDF/A is a family of standards for PDFs meant to be kept; a file conforms to the part and level its XMP metadata states, such as 2b or 2u, where u adds text that maps to Unicode.
- LaTeX exports PDF/A with
\DocumentMetadata{…, pdfstandard=a-2u}before\documentclass, Typst with--pdf-standard a-2u, and Typst stops at export where LaTeX writes a file that does not conform. - veraPDF validates against one profile at a time, and its report names the clause and the reason for every failed rule, which points at the fix.
- A submission check covers more than PDF/A: A4 pages, embedded fonts, a title and an author, and the size limit.
- Rewriting a PDF after export can silently remove its conformance, so shrink the inputs instead, and validate after any program has touched the file.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is PDF/A?
The ISO 19005 family of standards for PDFs meant to be kept: the file carries what it needs to show the same pages later, its fonts among them, and names its part and level in its XMP metadata.
-
How do PDF/A-2b and PDF/A-2u differ?
Both follow part 2, which is based on PDF 1.7; level u also promises that all the text maps to known Unicode code points, so it can be searched and copied.
-
How do you ask for PDF/A-2u when you export?
LaTeX: \DocumentMetadata{lang=en-IN, pdfstandard=a-2u} above \documentclass. Typst: typst compile --pdf-standard a-2u.
-
Why do pdfx and hyperxmp not belong in a new document?
The tagging status list marks them as not supported with the new PDF management; set standards with the keys of \DocumentMetadata instead.
-
How do you choose the profile veraPDF checks against?
--flavour 2u for a given part and level, or --flavour 0 for the profile the file's own metadata names; --format json gives a report a program can read.
-
What did a figure with Helvetica not embedded do in each system?
pdflatex built the thesis without a warning and veraPDF failed rule 6.2.11.4.1; Typst refused the PDF image at export. Embed the font with Ghostscript, or convert the figure to SVG for Typst.
-
What did the first-try thesis fail in the submission check?
US letter pages, the report class's default; a font not embedded; no title or author in the metadata; and no PDF/A claim. Only its file size passed.
-
Which rewrites of a PDF/A-2u file broke it?
Ghostscript's /ebook settings removed its PDF/A identification, and qpdf --linearize broke one rule, both without a word. qpdf with --newline-before-endstream and mutool clean kept it compliant.
Terms in this lesson
- conformance level
- The letter after a PDF/A part, as in PDF/A-2b or 2u: b for the basic rules, u for text that maps to Unicode as well, a for an accessible tagged file as well.
- output intent
- A colour profile embedded in a PDF that says how its device colours, such as plain grey or RGB, are meant to look. PDF/A requires one when such colours are used.
- PDF/A
- The ISO 19005 family of standards for PDFs meant to be kept, whose files must carry what they need to show the same pages later, such as their fonts, and name their part and level in their metadata.
- validation profile
- The set of rules that veraPDF checks a file against, one profile for each part and level of PDF/A or PDF/UA, such as the 146 rules of PDF/A-2u.
- XMP metadata
- The block of XML inside a PDF that describes the file, such as its title, its author and, in a PDF/A file, the part and conformance level it claims. pdfinfo -meta prints it.
References
- LaTeX tagging project: usage instructions (\DocumentMetadata) (The LaTeX Project)
- pdfmanagement: LaTeX PDF management code (CTAN)
- Tagging status of LaTeX packages and classes (The LaTeX Project)
- Typst reference: PDF (exporting and PDF standards) (Typst)
- veraPDF: the PDF/A and PDF/UA validator (veraPDF Consortium)
- veraPDF CLI validation (veraPDF Consortium)
- qpdf manual: command-line options (newline before endstream) (qpdf)
- Ghostscript: high-level output devices (pdfwrite) (Artifex)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress