CLI use cases
Four architectures on pdfnative-cli v1.5.0 (pdfnative ≥ 1.8.0, Node ≥ 22), each assembled from flags that ship today. They extend the ecosystem use cases with what only a command line does well — byte-reproducible builds, a PDF/X-4 press handoff, a house font without a code change and a process contract any language can drive — and every flag below is documented in the CLI guide.
Each case follows the hub's convention: the problem, the load-bearing invocation, what you gain and where it stops — with an architecture diagram where data crosses components. The companion pages cover the same ground for the MCP server and the React renderer.
Case 1 — Byte-reproducible document builds#
A rendered PDF carries the wall clock in four places: /Info /CreationDate,
xmp:CreateDate, the {date} header and footer placeholder, and the trailer
/ID (an MD5 over the title, the creation date and the object count). Two
builds of the same template therefore never hash the same, and a release
pipeline cannot tell "the template changed" from "the clock moved". Since
pdfnative 1.8.0 every date is written in UTC, and v1.5.0 adds the global
--creation-date <iso8601> — with SOURCE_DATE_EPOCH as the fallback the
reproducible-builds.org variable that distribution build tools (Debian, Nix) set — so that
unencrypted output becomes a pure function of its inputs: the same JSON
renders to the same bytes on every host, in every timezone.
# Any CI shell — the pin travels through the environment, or as a global flag.
export SOURCE_DATE_EPOCH=1767225600 # 2026-01-01T00:00:00Z
pdfnative --json --quiet render --input templates/report.json \
--output out/report.pdf --tagged pdfa2b --font latin --lang latin \
2> status.json # {"ok":true,…,"creationDate":"2026-01-01T00:00:00.000Z"}
sha256sum out/report.pdf # identical on Linux, macOS and Windows, in any TZ
# The same pin, explicit — global flags may precede the command since v1.5.0,
# and every render inside a manifest run inherits it.
pdfnative --creation-date 2026-01-01T00:00:00Z batch --manifest pipeline.json
# Across engine versions, gate on content instead of bytes.
pdfnative compare golden/report.pdf out/report.pdf --mode both --format json
Precedence is explicit: the --creation-date flag wins over
layout.creationDate in the document or a --layout file, which wins over
SOURCE_DATE_EPOCH, which wins over the wall clock. An unparsable value in
either the flag or the variable is a usage error (exit 2), never a silent
fall-back to the clock — that would defeat the convention. The pin becomes the
process-wide default, so a batch --manifest run applies it to every task,
and the --json envelope carries creationDate whenever a pin is active — the
one place to notice a SOURCE_DATE_EPOCH inherited from a build shell nobody
remembers exporting.
What you gain, concretely:
- Hash-based release gating — a SHA-256 in the artefact manifest becomes a real assertion: a changed hash means a changed template, font or engine, not a changed clock.
{date}footers that stop drifting — the placeholder follows the pinned instant, so a dated footer no longer breaks the golden file every morning.- Content-addressed caches and deduplication — identical input yields identical bytes, so a cache keyed on the JSON is a cache keyed on the PDF.
- One flag for a whole pipeline — the pin covers every render of a
batchrun without touching the manifest.
The other surfaces pin the same instant their own way: the MCP server reads
PDFNATIVE_MCP_CREATION_DATE and then SOURCE_DATE_EPOCH at start-up, while
the React renderer takes <Document creationDate> per document or
setDefaultCreationDate per process and deliberately reads no environment
variable.
Honest limits: encrypted output is never reproducible — the file keys come
from a CSPRNG by design. sign appends an incremental revision whose /ID is
drawn at signing time, and sign --signing-time and metadata --mod-date are
separate, deliberately unpinned instants: a signature's time has a legal
meaning of its own. An engine upgrade legitimately changes bytes without
changing content — pdfnative 1.8.0 itself moved every date to UTC and kept
the hinting tables in TrueType subsets — so rebaseline once per upgrade, or
gate with compare --mode both, which survives exactly that.
Case 2 — Press handoff: PDF/X-4 from a JSON template#
A printer's preflight is the most expensive place to learn that a logo was RGB, a form font was not embedded or the output intent was missing: the job is already late. PDF/X-4 (ISO 15930-7) is the exchange format press workflows expect, and v1.5.0 writes it from the same JSON template as every other document — with the claim refused at build time when its prerequisites do not hold, rather than discovered at the printer.
# The template carries CMYK colours ("c m y k" in 0–1 or [c,m,y,k] in percent),
# layout.print.bleed (derives the TrimBox) and layout.print.marks.colourBars.
# --output-intent-icc: the printer's prtr profile (16 MiB cap);
# --trapped: PDF/X needs true|false, never unknown;
# --font latin --lang latin: every font embedded;
# --strict: PDFX_* diagnostics fail before any byte.
pdfnative render --input templates/brochure.json --output out/brochure.pdf \
--pdfx pdfx4 \
--output-intent-icc press/printer.icc \
--output-intent-id "ISO Coated v2 (ECI)" \
--trapped false \
--font latin --lang latin \
--strict
# Same verdict, nothing written — --dry-run runs the real build in memory.
pdfnative --json --dry-run render --input templates/brochure.json \
--pdfx pdfx4 --output-intent-icc press/printer.icc --trapped false \
--font latin --lang latin --strict
# Assert the claim on any file: exit 0 when the prerequisites hold, 1 otherwise.
pdfnative inspect --input out/brochure.pdf --check pdfx --json --summary
Two layers refuse a bad file. Engine coherence errors — no output intent, an
ICC profile without the acsp signature, layout.pdfx combined with
layout.tagged, an unknown trapping state — map to E_INPUT and produce no
output at all. Under --strict, the conformance diagnostics
PDFX_NO_FONT_ENTRIES, PDFX_DEVICE_CMYK and PDFX_ANNOTATIONS escalate to
E_CHECK_FAILED before the first output byte; without it they are stderr
warnings and a diagnostics[] array in the envelope, which also carries
pdfx: "pdfx4". On the receiving side, inspect always reports the XMP claim
as pdfxConformance, --pdfx adds the full structural report and
--check pdfx turns it into an exit code; --summary keeps the verdict to
five fields.
What you gain, concretely:
- The claim is refused at build time — a template that cannot be PDF/X-4 never produces a file that says it is.
- A dry-run verdict without writing — the same errors, escalations and
diagnostics as the real run, with
dryRun: trueand nobytesin the envelope; nothing touches the disk. - The profile comes from the command line —
--output-intent-iccreads a path you name (or the layout JSON carries the profile as an inline byte array, under the same 16 MiB cap); no JSON payload can ever name a file on disk. - One template, two markets — the same JSON renders to PDF/A for the archive and PDF/X-4 for the press by changing the conformance flags.
The MCP server exposes the same claim as pdfx: 'pdfx4' on its generation
tools and checks it with validate_pdf and standard: 'pdf-x-4'; the React
renderer takes pdfx="pdfx4" on <Document> and leaves the check to the
engine's validatePdfX, one import away.
Honest limits: inspect --check pdfx is pdfnative's structural validator,
not a certified preflight — it checks the output intent, the page boxes,
font embedding and forbidden annotations, not colour appearance or rendering;
the printer's preflight still runs. Only PDF/X-4 is exposed: no PDF/X-1a, no
PDF/X-3, no spot colours. metadata rewrites the XMP without the PDF/X
identification, so re-check any file you edit afterwards. The claim is
exclusive with PDF/A (--tagged) and with encryption — the pre-check exits 2
before the build. And no press profile ships: the synthetic-cmyk.icc under
the CLI's samples is structurally valid and colourimetrically meaningless —
ask the printer for theirs.
Case 3 — A house font and book typesetting without a code change#
Corporate templates arrive with a licensed house font, and long documents need
the rules a typesetter takes for granted — widows and orphans, a heading that
never ends a page alone, paragraphs that may split, kerning, old-style
numerals. Before v1.5.0 a font outside the bundled set meant a wrapper script
around the library, and the typography options needed TypeScript. Now
--font-file registers the file from disk and four flags plus
layout.typography cover the rest — from the command line, against the
same JSON.
# House font from disk, typesetting rules from the layout file — no TypeScript.
# --font-file: TTF/OTF, 32 MiB cap, validated before registration;
# book/typography.json holds {"typography":{"widows":2,"orphans":2,"opticalMargins":true}};
# --strict: TYPOGRAPHY_FEATURE_INEFFECTIVE fails the build.
pdfnative render --input book/manuscript.json --output out/book.pdf \
--font-file fonts/HouseSerif-Regular.ttf:house-serif \
--split-paragraphs --keep-headings-with-next \
--kerning --font-features onum,smcp \
--layout book/typography.json \
--strict
# Preflight the font and the feature tags without writing anything.
pdfnative --json --dry-run render --input book/manuscript.json \
--font-file fonts/HouseSerif-Regular.ttf:house-serif --font-features onum,smcp
--font-file <path.ttf>[:name] is repeatable and guarded the same way as
every other binary input: the path is checked against traversal, the file is
capped at 32 MiB, the sfnt signature is required (TrueType collections and
WOFF containers are refused), and the program is parsed and then validated
with the engine's validateFontData before it is registered — format and
validation failures are E_INPUT, warnings go to stderr. The name defaults
to the file stem ([a-z0-9-]); a malformed name or one that collides with a
bundled shortcut is a usage error (exit 2). The name is added to --lang, so
the font is embedded as a CIDFont Type2 subset like every other.
Fonts are never loaded from a JSON payload — only from this flag.
The four flags are shortcuts for the most common layout.typography keys; the
full set (widows, orphans, splitParagraphs, keepHeadingsWithNext,
opticalMargins, punctuationSpacing, unitBinding, bindShortWords,
kerning, fontFeatures, metrics, hyphenationLanguage) lives in the JSON — the document, a
--layout file and the flags merge one level deep, flags winning. Paragraph
blocks accept align: "justify", keepWithNext and splittable, and any
text may carry soft hyphens (U+00AD). Under --strict, a feature tag the font
does not implement is not a silent no-op: TYPOGRAPHY_FEATURE_INEFFECTIVE
fails the build, and --dry-run --json reports it in diagnostics[] before
anything is written.
What you gain, concretely:
- A house font without a build step — the licensed TTF stays a file on disk, registered per invocation.
- Book rules in data, not code — a
typography.jsonreviewed like any other configuration. - Feature tags that are asserted — the build says when
onumorsmcpchanged nothing in this font, instead of shipping lining numerals quietly.
Honest limits: there is no hyphenation dictionary — the engine ships a
provider seam and no patterns, the CLI does not expose the seam, and
hyphenationLanguage passes through with nothing to drive; the only break
opportunities inside a word are the soft hyphens the author wrote, which are
honoured unconditionally. metrics: "exact" acts on the base-14 path only —
once a font is registered, that font measures the text. A custom font covers
only the code points in its own cmap; everything else falls to the other
registered fonts, so pair it with --font latin --lang house-serif,latin for
a Latin fallback — --font alone registers the module without embedding it,
and the first font in --lang order that covers a code point wins, so the
house face goes first.
--kerning needs a registered font. And tnum on Noto Sans is a no-op — the
bundled module declares the feature, but its figures are already tabular and
lining, so the tag substitutes nothing, which is exactly what the diagnostic
reports.
Case 4 — Any-language orchestrator on the process contract#
The MCP server is the right integration for a conversational assistant; a
Python job queue, a Go service or a Jenkins step wants a process contract,
not a protocol. The CLI's contract has three channels and nothing else: the
artefact on stdout, diagnostics and a JSON envelope on stderr, and an exit
code of 0 (success), 1 (runtime error) or 2 (usage error). Under --json the
envelope is always the last line of stderr, --quiet drops everything
before it, and every failure carries one of 12 stable E_* codes to branch
on — never the human message.
import json, subprocess
def pdfnative(*args: str) -> tuple[int, dict | None, str]:
"""One process per task: (exit code, stderr envelope or None, stdout artefact)."""
p = subprocess.run(["pdfnative", "--json", "--quiet", *args], capture_output=True, text=True)
lines = p.stderr.strip().splitlines()
envelope = json.loads(lines[-1]) if lines else None # the envelope is the LAST stderr line
failed_check = p.returncode == 1 and envelope is not None and envelope["error"]["code"] == "E_CHECK_FAILED"
if p.returncode != 0 and not failed_check: # 1 = runtime error, 2 = usage error
raise RuntimeError(f"{envelope['error']['code']}: {envelope['error']['message']}" if envelope else f"exit {p.returncode}")
return p.returncode, envelope, p.stdout
pdfnative("render", "--input", "doc.json", "--output", "out.pdf", "--dry-run") # preflight; never touches the network
_, status, _ = pdfnative("render", "--input", "doc.json", "--output", "out.pdf") # status["bytes"]; status["creationDate"] when pinned
code, _, verdict = pdfnative("inspect", "--input", "out.pdf", "--check", "pdfa", "--summary") # verdict JSON on stdout; code 0 pass, 1 fail
The contract is discoverable at runtime. pdfnative schema manifest emits one
JSON document listing every command, its flags, the global flags and the
error codes — enough to register the CLI as a tool set without reading a
manual — and schema render, schema status and the other subjects are
JSON Schemas (Draft 2020-12) whose $id embeds the CLI version, so your own
validator checks the input before the process starts and detects drift after
an upgrade. --dry-run validates the same inputs the real run would — JSON
parsed, layout assembled, fonts and profiles read, credentials loaded — and
never performs network I/O on the commands that implement it (render,
sign, ltv, doc-timestamp, batch), even when a network flag is
present; verify has no dry run, so --revocation online always fetches.
What you gain, concretely:
- One contract for 21 commands — render, sign, verify, merge, extract, compare and the rest all answer through the same three channels and the same 12 codes.
- Token-cheap stdout —
--summarycollapsesinspect,verifyandbatchto the fields an orchestrator gates on,--fields a,b.cprojects any path, and the JSON is compact by default under--json. - Offline by default — the only network opt-ins are explicit flags
(
sign --timestamp,ltv collect|add --online,doc-timestamp --url,verify --revocation online), each behind an SSRF guard, each failing withE_NETWORKrather than a silent fallback.
Honest limits: inspect, verify and batch put their result on stdout as
JSON, not in the envelope — on success there may be no stderr line at all,
which is why the wrapper above returns both channels. ltv and compare take
positional arguments and are not callable from a batch --manifest pipeline
yet; run them as their own process. And the CLI is stateless by design — one
process per task, no daemon, no session — so concurrency, retries and queueing
are the orchestrator's job, driven by the exit code.
See also#
- Ecosystem use cases — the hub: five cross-surface architectures, including the CI regression gate and the air-gapped PAdES ladder these cases build on.
- CLI guide — the complete v1.5.0 command reference behind every flag above.
- MCP use cases and React use cases — three cases each, the same engine seen from the other two surfaces.
- CLI playground — compose a
pdfnativeinvocation in the browser. - Reproducible output playground — pin a creation date and watch the bytes stay identical.