<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vitalii Nemudryi</title>
    <description>The latest articles on DEV Community by Vitalii Nemudryi (@pdfik).</description>
    <link>https://dev.to/pdfik</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4096032%2F6550a491-03ed-44aa-a7ff-6cde173e03a6.png</url>
      <title>DEV Community: Vitalii Nemudryi</title>
      <link>https://dev.to/pdfik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pdfik"/>
    <language>en</language>
    <item>
      <title>Chromium in Docker without --no-sandbox: what actually breaks</title>
      <dc:creator>Vitalii Nemudryi</dc:creator>
      <pubDate>Fri, 18 Sep 2026 18:09:10 +0000</pubDate>
      <link>https://dev.to/pdfik/chromium-in-docker-without-no-sandbox-what-actually-breaks-437l</link>
      <guid>https://dev.to/pdfik/chromium-in-docker-without-no-sandbox-what-actually-breaks-437l</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I'm Vitalii, founder of &lt;a href="https://pdfik.net" rel="noopener noreferrer"&gt;PDFik&lt;/a&gt;, a hosted URL/HTML-to-PDF API. The product shows up once near the end, clearly marked. The rest is what running sandboxed Chromium in production actually looks like — including the part where our own security audit got it wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have ever put headless Chromium in a container, you have probably met one of these two messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:zygote_host_impl_linux.cc - No usable sandbox! […]
If you want to live dangerously and need an immediate workaround, you can try using --no-sandbox.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you have certainly seen the standard fix, repeated in countless Dockerfiles, quickstarts and accepted Stack Overflow answers: pass &lt;code&gt;--no-sandbox&lt;/code&gt;. It makes the error go away immediately, so it spread everywhere — to the point where the flag reads like a required incantation for "Chrome in Docker" rather than what it actually is: switching off the browser's main defense against the content it renders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even our own security audit believed the myth
&lt;/h2&gt;

&lt;p&gt;We run a PDF-rendering service: worker pods pull jobs from a queue and render customer-submitted URLs or HTML in headless Chromium via Playwright. The worker container is deliberately unfriendly — non-root, all Linux capabilities dropped, privilege escalation forbidden, read-only root filesystem.&lt;/p&gt;

&lt;p&gt;During a security audit of that setup, the auditor looked at the pod spec and wrote the finding as fact: with a configuration this locked down, Chromium cannot even start without &lt;code&gt;--no-sandbox&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Except the flag had already been removed in an earlier hardening round. The same locked-down pods had been rendering untrusted pages in production the whole time, sandbox on, end-to-end suite green.&lt;/p&gt;

&lt;p&gt;I like this story because nobody in it is careless. The auditor pattern-matched the same folklore every tutorial repeats, and the folklore contains a real kernel of truth: in that container, one of Chromium's two sandboxes genuinely cannot work. The only mistake is not knowing there are two.&lt;/p&gt;

&lt;p&gt;(The audit round still earned its keep: it is why the pod now pins a seccomp profile explicitly (RuntimeDefault) instead of running unconfined, which is what Kubernetes gives you by default when the field is absent. More on that below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The two sandboxes, in plain words
&lt;/h2&gt;

&lt;p&gt;Chromium splits itself into a trusted browser process and untrusted renderer processes. The renderers execute whatever the page supplies — HTML parsing, JavaScript, images, fonts. Renderer bugs are found and patched all the time; the sandbox is the wall that decides what a successful exploit is worth. With the wall, code execution in a renderer lands in a process that has no filesystem, no network sockets of its own and no real user identity, and still needs a second, unrelated bug to get anywhere. Without the wall, it lands directly in your worker process: your environment variables, your tokens, your network position.&lt;/p&gt;

&lt;p&gt;To build the wall, Chromium has two mechanisms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setuid sandbox&lt;/strong&gt; is the legacy one: a small root-owned helper binary that briefly elevates to root to construct the isolation, then drops everything. In a hardened container this path is dead on arrival — and that is on purpose. &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt; sets the kernel's &lt;code&gt;no_new_privs&lt;/code&gt; bit on the process, which means: nothing this process executes may ever gain privileges it does not already have. That bit exists precisely to neutralize setuid tricks. You did not want a root-elevating helper in your image anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The user-namespace sandbox&lt;/strong&gt; is the modern one, and it needs no privileges at all — when the environment permits it. An ordinary process asks the kernel for a new &lt;em&gt;user namespace&lt;/em&gt;: a private view of the system in which the process is "root" while remaining a nobody outside. Chromium uses that namespace-root to strip its renderers of the filesystem, the network and everything else. Two switches decide whether this works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The kernel must allow unprivileged user namespaces.&lt;/strong&gt; Many distributions enable this by default; some disable or restrict it (Debian historically gated it behind a sysctl, recent Ubuntu restricts it through AppArmor). Inside the container, &lt;code&gt;cat /proc/sys/user/max_user_namespaces&lt;/code&gt; returning &lt;code&gt;0&lt;/code&gt; means the kernel says no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The seccomp profile must allow the namespace syscalls.&lt;/strong&gt; Seccomp is the allowlist of kernel calls a container may make. Docker's historic default profile blocked creating user namespaces — that block is exactly where &lt;code&gt;Failed to move to new namespace … Operation not permitted&lt;/code&gt; comes from, and where the &lt;code&gt;--no-sandbox&lt;/code&gt; cargo cult started.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both switches vary by distribution, container runtime and version, and both have flipped over the years. So do not memorize a compatibility table — probe. Remove the flag, launch once, read the error: the message tells you which mechanism failed and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pod spec that runs Chromium with its sandbox ON
&lt;/h2&gt;

&lt;p&gt;This is the real production configuration of our render workers on Kubernetes (EKS; the node kernel ships with unprivileged user namespaces enabled — the default on Amazon Linux 2023):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# pod-level&lt;/span&gt;
    &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;
    &lt;span class="na"&gt;fsGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;
    &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeDefault&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;render-worker&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# container-level&lt;/span&gt;
        &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;readOnlyRootFilesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a read-only root filesystem, Chromium still needs somewhere to write, so three &lt;code&gt;emptyDir&lt;/code&gt; volumes are mounted: &lt;code&gt;/tmp&lt;/code&gt;, the app's own scratch directory, and the browser user's home. The image creates a real non-root user (uid 1000) with a home directory — Chromium wants one for its profile. And here is the complete list of Chromium flags we pass — all two of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;headless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--disable-dev-shm-usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--disable-gpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;--no-sandbox&lt;/code&gt;, no &lt;code&gt;--disable-setuid-sandbox&lt;/code&gt;, no added capabilities, no custom seccomp JSON. &lt;code&gt;--disable-dev-shm-usage&lt;/code&gt; is there because a container's &lt;code&gt;/dev/shm&lt;/code&gt; is 64 MB by default and Chromium crashes on heavy pages when shared memory runs out; the flag makes it use ordinary temp files instead. (The alternative is mounting a memory-backed volume on &lt;code&gt;/dev/shm&lt;/code&gt;.) &lt;code&gt;--disable-gpu&lt;/code&gt; because there is no GPU to find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One operational consequence to plan for:&lt;/strong&gt; the namespace sandbox depends on the &lt;em&gt;node's&lt;/em&gt; kernel settings, which you usually do not manage directly. A node-image upgrade that disables unprivileged user namespaces will not degrade your service politely — Chromium will simply refuse to launch. That is the right failure direction (closed), but it is still an outage, so make it loud and early. Our workers refuse to come up without the browser pool: if Chromium cannot launch, the process exits during startup and the replacement pods crash-loop, so a missing sandbox prerequisite reads as "the rollout never goes healthy" at deploy time, not as customer-facing errors an hour later. (A separate liveness probe catches the other failure mode: a browser pool or event loop that wedges after startup.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What the sandbox does nothing about
&lt;/h2&gt;

&lt;p&gt;Here is the part most "secure headless Chrome" writeups skip: a perfectly sandboxed browser still makes network requests on behalf of untrusted content, because fetching things is what a browser is for. If you render customer-submitted URLs from inside your infrastructure, the bigger everyday risk is not a renderer exploit — it is the page politely asking for things it should never reach: &lt;code&gt;http://169.254.169.254/…&lt;/code&gt; (the cloud metadata service that hands out credentials), your database, internal dashboards. The sandbox is completely indifferent to all of that.&lt;/p&gt;

&lt;p&gt;So the second half of rendering untrusted content responsibly is controlling what the browser can reach — and the obvious first tool, application-level URL filtering, has traps we have personally hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Validate-then-fetch is a race.&lt;/strong&gt; You resolve &lt;code&gt;invoice.example.com&lt;/code&gt;, get a public IP, approve the URL — and the browser resolves the name &lt;em&gt;again&lt;/em&gt; at connect time, when the attacker's DNS can answer with a private address. This is DNS rebinding. The counter is to resolve &lt;strong&gt;once&lt;/strong&gt;, validate &lt;strong&gt;every&lt;/strong&gt; address the name resolves to, and pin the connection to a validated IP wherever your HTTP client allows it. Inside Chromium you effectively cannot pin — the browser re-resolves on its own. Hold that thought; it is why the network-policy section below exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The standard library's idea of "private" has a hole in it.&lt;/strong&gt; Python shown, but the same trap exists in other stacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;is_private&lt;/span&gt;
&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100.64.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;is_private&lt;/span&gt;
&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;100.64.0.0/10&lt;/code&gt; is RFC 6598 shared address space (carrier-grade NAT), deliberately classified as neither private nor global — and inside cloud networks that range can be very much alive. A blocklist built on &lt;code&gt;is_private&lt;/code&gt; alone waves it through. Block it explicitly, along with loopback, link-local, multicast, reserved and unspecified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. IPv6 can smuggle IPv4.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;64:ff9b::169.254.169.254&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;is_private&lt;/span&gt;
&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;64:ff9b::/96&lt;/code&gt; is the well-known NAT64 prefix: the last four bytes are an embedded IPv4 address, and a NAT64-capable egress path will deliver the packet to that inner address — here, the metadata service. To the standard library the outer address is ordinary global IPv6 space. The same wrapper trick exists in IPv4-mapped (&lt;code&gt;::ffff:a.b.c.d&lt;/code&gt;) and IPv4-translated forms; newer language versions unwrap &lt;em&gt;some&lt;/em&gt; of them in &lt;em&gt;some&lt;/em&gt; checks. Do not memorize which: if an IPv6 address embeds an IPv4 address, extract the inner address and judge that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The first URL is not the last URL.&lt;/strong&gt; Validating what the customer submitted misses everything that happens next: redirects (&lt;code&gt;302 → http://169.254.169.254/…&lt;/code&gt;) and every subresource the page pulls in — images, iframes, stylesheets, fonts. Subresources you can catch by hooking the browser's network layer: in Playwright, a route handler sees every request the page makes and can re-validate each one. Redirect hops you cannot: Playwright's documented behavior is that the handler "will only be called for the first url if the response is a redirect" — and that holds for &lt;code&gt;context.route&lt;/code&gt; too — so the browser follows a 302 without your handler ever seeing the new URL. To police redirects at this layer you have to take over the fetch inside the handler (&lt;code&gt;route.fetch(max_redirects=0)&lt;/code&gt;, validate each &lt;code&gt;Location&lt;/code&gt; target yourself, then &lt;code&gt;route.fulfill()&lt;/code&gt;), or refuse redirects on render targets outright. Otherwise a redirect to the metadata service passes the hook untouched — one more reason the next section exists. In Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;interceptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Resolve-once + blocklist check on every request the page makes.
&lt;/span&gt;        &lt;span class="c1"&gt;# NOTE: Playwright does NOT call this handler for redirect hops —
&lt;/span&gt;        &lt;span class="c1"&gt;# only for the first URL of a chain. Police redirects with
&lt;/span&gt;        &lt;span class="c1"&gt;# route.fetch(max_redirects=0) here, or leave them to the
&lt;/span&gt;        &lt;span class="c1"&gt;# egress policy below.
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;validate_url_for_ssrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;SSRFError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accessdenied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;continue_&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interceptor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If your jobs can carry customer credentials for authenticated rendering, this hook is also the place to attach them — per request, only when the request's origin matches the target's origin. Never install a credential context-wide: every third-party subresource on the page would receive it. And the redirect caveat applies here too: your handler does not see redirect hops, so verify with a live test what your browser and interception layer actually do with an injected header across a cross-origin redirect, rather than assuming the hook will strip it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer we actually trust
&lt;/h2&gt;

&lt;p&gt;Everything in the previous section runs inside the same workload that renders hostile content, and one of its counters — IP pinning — is not even fully available in a browser. So the honest architecture statement is: application-level SSRF filtering is best-effort, and the control we actually rely on sits one level down — a kernel-enforced egress policy on the render pods. In Kubernetes terms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;render-workers-egress&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;render-workers&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Egress"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# DNS&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;UDP&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;53&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TCP&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;53&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# The database — and no other private destination&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ipBlock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cidr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;&amp;lt;your-db-subnet&amp;gt;&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TCP&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5432&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# Public HTTP/HTTPS: render targets, object storage, queues&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TCP&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;80&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;TCP&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;443&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ipBlock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cidr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;
            &lt;span class="na"&gt;except&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;10.0.0.0/8&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;100.64.0.0/10&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;172.16.0.0/12&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.0.0/16&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;169.254.0.0/16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this policy enforced, a page that gets past every software check above finds its packets to the metadata service or the internal network dropped by infrastructure the rendered content has no influence over.&lt;/p&gt;

&lt;p&gt;Two scars to save you time here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A NetworkPolicy is a request, not a fact.&lt;/strong&gt; Kubernetes happily accepts NetworkPolicy objects on clusters where nothing enforces them — on EKS with the AWS VPC CNI, enforcement is a flag you must explicitly turn on. Ours sat in exactly that state for a while: accepted, visible in &lt;code&gt;kubectl&lt;/code&gt;, doing nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An unenforced policy accumulates bugs invisibly.&lt;/strong&gt; When enforcement was finally switched on, probing from a live worker pod found three defects that had been sitting in that green-looking YAML the whole time: the blanket private-range block also covered the range our own database lives in, so workers could not record job results; only port 443 had been allowed, so any plain-&lt;code&gt;http://&lt;/code&gt; render target would hang; and there was an allow rule for a Redis connection the workers never actually make. The policy had reviewed well and meant nothing. So: after enabling enforcement, prove both directions &lt;em&gt;from inside a pod&lt;/em&gt; — the metadata request must fail, the database connection must succeed, an &lt;code&gt;http://&lt;/code&gt; fetch must succeed — and grant only what you can demonstrate the workload uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;If you render untrusted URLs or HTML with headless Chromium, in rough order of value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the sandbox. Do not pass &lt;code&gt;--no-sandbox&lt;/code&gt;; when launch fails, fix the switch the error names instead — kernel user-namespace sysctl, seccomp profile, non-root user with a writable home. If your runtime's default seccomp profile blocks the namespace syscalls, prefer a tailored profile over running unconfined.&lt;/li&gt;
&lt;li&gt;Harden the container anyway: &lt;code&gt;runAsNonRoot&lt;/code&gt;, &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt;, &lt;code&gt;capabilities: drop ALL&lt;/code&gt;, &lt;code&gt;readOnlyRootFilesystem&lt;/code&gt; with &lt;code&gt;emptyDir&lt;/code&gt; for &lt;code&gt;/tmp&lt;/code&gt; and the home directory, an explicit seccomp profile.&lt;/li&gt;
&lt;li&gt;Prove at startup that the browser actually launches, and fail the rollout loudly — a node-image change can silently remove a sandbox prerequisite.&lt;/li&gt;
&lt;li&gt;One browser context per job, downloads off, popups closed, context torn down in &lt;code&gt;finally&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Validate URLs with resolve-once semantics; block CGNAT explicitly; unwrap IPv4-embedding IPv6 forms; re-validate every request in a network hook — subresources included — and know your tool's redirect behavior: Playwright's route handler does not see redirect hops, so police redirects separately or leave them to the egress layer below.&lt;/li&gt;
&lt;li&gt;Put the real guarantee below the application: a kernel-enforced egress policy that denies private ranges, CGNAT and link-local, allows only DNS, your database and public 80/443 — then prove it empirically from inside a pod.&lt;/li&gt;
&lt;li&gt;Assume each layer fails. The useful design question is never "is this isolated?" — it is "what does the next layer catch?"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The plug, as promised
&lt;/h2&gt;

&lt;p&gt;PDFik is roughly this article as a service: a hosted URL/HTML-to-PDF API where the posture above is the default — Chromium with its sandbox on, a fresh browser context per job, per-request SSRF re-validation, and network-level egress restrictions on the render fleet — all running on AWS in the EU (Frankfurt). There is a free plan and it does not ask for a credit card: &lt;a href="https://pdfik.net" rel="noopener noreferrer"&gt;pdfik.net&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you spot a hole in any of this, I genuinely want to know — that is half the reason to write it up.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>kubernetes</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Factur-X / ZUGFeRD from HTML: how to embed EN 16931 XML in a PDF/A-3 and actually pass veraPDF and Mustang</title>
      <dc:creator>Vitalii Nemudryi</dc:creator>
      <pubDate>Tue, 08 Sep 2026 14:22:49 +0000</pubDate>
      <link>https://dev.to/pdfik/factur-x-zugferd-from-html-how-to-embed-en-16931-xml-in-a-pdfa-3-and-actually-pass-verapdf-and-pgn</link>
      <guid>https://dev.to/pdfik/factur-x-zugferd-from-html-how-to-embed-en-16931-xml-in-a-pdfa-3-and-actually-pass-verapdf-and-pgn</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I'm Vitalii, founder of &lt;a href="https://pdfik.net" rel="noopener noreferrer"&gt;PDFik&lt;/a&gt;, a hosted URL/HTML-to-PDF API. We shipped Factur-X output this week, and this article is the write-up of what it took. PDFik appears once near the end, clearly marked, next to the self-hosted paths that compete with it. If you must keep invoices on your own infrastructure, the self-hosted section is for you and the hosted one is not.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you already render invoices from HTML, turning them into hybrid e-invoices looks like a small step: attach an XML file to the PDF. It is not a small step. The container has to be PDF/A-3, the attachment has to carry the right relationship, the metadata has to declare a custom schema, and two of the most common mistakes pass both validators everyone uses.&lt;/p&gt;

&lt;p&gt;Here is what a valid file actually contains, the pipeline we ended up with, the traps that cost us days, and three ways to build it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a hybrid PDF at all
&lt;/h2&gt;

&lt;p&gt;Factur-X (France) and ZUGFeRD 2.x (Germany) are the same standard under two names: one PDF that a human reads, with a UN/CEFACT Cross Industry Invoice (CII) XML embedded for the accounting system. The XML follows the European semantic model EN 16931.&lt;/p&gt;

&lt;p&gt;Where this matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Germany.&lt;/strong&gt; Domestic businesses have had to be able to &lt;em&gt;receive&lt;/em&gt; e-invoices since 1 January 2025 — small businesses included, even though they are exempt from issuing them. Issuing is phased in through a transition rule: until 31 December 2026 any issuer may still send an "ordinary invoice" instead, and until 31 December 2027 if their previous year's turnover was €800,000 or less. Note what an ordinary invoice means there: paper, or an unstructured electronic file such as a plain PDF — and the electronic variant, exactly as before, may only be used if the recipient agrees to that format. So in practice the duty to issue bites from 2027 for larger companies and from 2028 for the rest. The BMF names XRechnung and ZUGFeRD from version 2.0.1 as satisfying the VAT-law requirements, with the MINIMUM and BASIC-WL profiles explicitly excluded (&lt;a href="https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html" rel="noopener noreferrer"&gt;BMF FAQ&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;France.&lt;/strong&gt; Since 1 September 2026 every business covered by the reform must be able to receive electronic invoices, and reception runs through an approved platform. Large enterprises and mid-caps (ETI) must also &lt;em&gt;issue&lt;/em&gt; from that same date; SMEs, small firms and micro-enterprises must issue from 1 September 2027 (&lt;a href="https://www.impots.gouv.fr/sites/default/files/media/1_metier/2_professionnel/EV/2_gestion/290_facturation_electronique/guide_pratique_facturation_electronique.pdf" rel="noopener noreferrer"&gt;DGFiP start-up guide, PDF&lt;/a&gt;). Approved platforms are required to transmit invoices in one of three formats — CII, UBL, or Factur-X, described there as a mixed format of structured XML plus a readable PDF (&lt;a href="https://www.impots.gouv.fr/sites/default/files/media/1_metier/2_professionnel/EV/2_gestion/290_facturation_electronique/fiches_reforme/fiche-1_f.pdf" rel="noopener noreferrer"&gt;DGFiP fiche, PDF&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Belgium&lt;/strong&gt; went the other way: B2B e-invoicing has been mandatory since 1 January 2026 over Peppol, and "sending a PDF invoice by e-mail or via a platform will no longer be enough" (&lt;a href="https://ec.europa.eu/digital-building-blocks/sites/spaces/DIGITAL/pages/467108877/eInvoicing+in+Belgium" rel="noopener noreferrer"&gt;source&lt;/a&gt;). The hybrid PDF is a courtesy copy there, not the legal document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poland's KSeF&lt;/strong&gt; takes structured XML only. The FA(3) logical structure replaced FA(2) on 1 February 2026 (&lt;a href="https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/struktura-logiczna-fa-3/" rel="noopener noreferrer"&gt;structure page&lt;/a&gt;), and issuing through KSeF has been mandatory since 1 February 2026 for the largest taxpayers and since 1 April 2026 for the rest — with temporary carve-outs running to the end of 2026, notably for issuers whose invoiced sales stay at or below 10,000 zł in a month (&lt;a href="https://ksef.podatki.gov.pl/informacje-ogolne-ksef-20/podstawy-prawne-oraz-kluczowe-terminy/" rel="noopener noreferrer"&gt;legal basis and key dates&lt;/a&gt;). Even an invoice "attachment" there is a structured element inside the FA(3) XML, and using it at all requires notifying the tax office first (&lt;a href="https://ksef.podatki.gov.pl/media/kmriagdg/broszura-informacyjna-dotyczaca-struktury-logicznej-fa-3.pdf" rel="noopener noreferrer"&gt;FA(3) brochure, PDF&lt;/a&gt;) — so a hybrid PDF has nothing to attach itself to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the hybrid PDF is the right target for Germany and France, and a human-readable extra everywhere else. Nothing below generates the XML for you. That is your accounting data, and every path here assumes you already have valid CII.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a valid Factur-X file actually requires
&lt;/h2&gt;

&lt;p&gt;Four layers, and every one of them can be wrong independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The container is PDF/A-3.&lt;/strong&gt; Chromium gives you a plain PDF. PDF/A-3b on top of it needs an OutputIntent with an embedded sRGB ICC profile, XMP metadata carrying &lt;code&gt;pdfaid:part=3&lt;/code&gt; and &lt;code&gt;pdfaid:conformance=B&lt;/code&gt;, every font embedded, no JavaScript and no encryption. PDF/A-3b permits transparency, so CSS opacity is not the problem it was under PDF/A-1.&lt;/p&gt;

&lt;p&gt;Chromium embeds what it renders. Across a dozen local test renders — Latin, Cyrillic, Greek, CJK, SVG text, form controls, a web font fetched from Google Fonts, a family that does not exist anywhere — every text font landed in the file as a subsetted CIDFontType2 with its outlines in &lt;code&gt;/FontFile2&lt;/code&gt;: one line of text pulled a 1 MB, 4,651-glyph face down to a 47 KB subset, and a character no installed font covers is drawn as the empty box out of a font that is already embedded. Two caveats, both of which cost us work. A font whose OS/2 &lt;code&gt;fsType&lt;/code&gt; forbids embedding is not embedded: Chromium converts it to a Type3 font and writes the glyph outlines into the PDF itself, which is a different construct from everything else in the file — worth putting through your own validator before you ship an invoice template built on a licensed corporate typeface. And Chromium neither installs fonts nor touches anything you draw outside it: our worker image adds DejaVu, Lato, Open Sans, Roboto and Noto core on top of the Playwright base, and the sandbox watermark registers and embeds its own TTF, failing the job outright when it cannot find one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The embedded file has the right name.&lt;/strong&gt; For Factur-X the attachment is &lt;code&gt;factur-x.xml&lt;/code&gt; for every profile, listed under &lt;code&gt;/Names/EmbeddedFiles&lt;/code&gt; and referenced from the catalog's &lt;code&gt;/AF&lt;/code&gt; array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The AFRelationship value.&lt;/strong&gt; The file specification carries &lt;code&gt;/AFRelationship&lt;/code&gt;, and Factur-X gives it meaning in section 6.2.2: &lt;code&gt;Data&lt;/code&gt; when the visual representation carries more invoice data than the XML, &lt;code&gt;Source&lt;/code&gt; when the visual representation was produced &lt;em&gt;from&lt;/em&gt; the XML, &lt;code&gt;Alternative&lt;/code&gt; when the two carry exactly the same invoice content in two forms. MINIMUM and BASIC WL never carry a complete invoice, so both countries require &lt;code&gt;Data&lt;/code&gt; there. For BASIC, EN 16931 and EXTENDED the two countries part ways: in France &lt;code&gt;Data&lt;/code&gt;, &lt;code&gt;Source&lt;/code&gt; or &lt;code&gt;Alternative&lt;/code&gt; are all allowed, "depending on how the PDF part was created", while for use in Germany the specification makes &lt;code&gt;Alternative&lt;/code&gt; mandatory — the word is "zwingend", on legal grounds, because the value asserts that the tax-relevant content of both representations is identical. That is sections 5.3 and 6.2.2 of Factur-X 1.07.2 / ZUGFeRD 2.3.2, 15 November 2024; the spec is free but &lt;a href="https://fnfe-mpe.org/factur-x/factur-x_en/" rel="noopener noreferrer"&gt;email-gated at fnfe-mpe.org&lt;/a&gt;, and &lt;a href="https://easyfirma.net/wp-content/uploads/2024/12/1.-FACTUR-X-1.07.2-DE.pdf" rel="noopener noreferrer"&gt;a third-party mirror of that edition&lt;/a&gt; is readable without the form. The current release is 1.09.2 / ZUGFeRD 2.5.2, whose &lt;a href="https://fnfe-mpe.org/factur-x/factur-x-et-zugferd/" rel="noopener noreferrer"&gt;published changes&lt;/a&gt; are code lists, validation artifacts and new EXTENDED elements — I have not read it, so treat the version above as the one these quotes come from. One thing worth being clear about: &lt;code&gt;Source&lt;/code&gt; is not our case. We render the PDF from your HTML and embed your XML, so the two are independent inputs and neither we nor a validator can tell whether they agree. &lt;code&gt;Alternative&lt;/code&gt; is a claim about your data, and it is yours to make true.&lt;/p&gt;

&lt;p&gt;Here is the trap. The Python &lt;code&gt;factur-x&lt;/code&gt; library defaults to &lt;code&gt;data&lt;/code&gt;, and quietly downgrades &lt;code&gt;alternative&lt;/code&gt; to &lt;code&gt;data&lt;/code&gt; only for the two small profiles. Call it with defaults for an EN 16931 invoice and you get a structurally perfect file with the wrong relationship, and nothing downstream objects. Mustang's validator never looks at the value at all — the word "relationship" does not appear in any of the nine source files of its &lt;a href="https://github.com/ZUGFeRD/mustangproject/tree/master/validator/src/main/java/org/mustangproject/validator" rel="noopener noreferrer"&gt;validator module&lt;/a&gt; — and veraPDF checks the container, not whether the PDF and the XML "say the same thing", which is not something a validator can decide from the bytes anyway. You have to pass the value explicitly and assert it in your own tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The XMP extension schema.&lt;/strong&gt; PDF/A only allows custom XMP properties that are declared in a &lt;code&gt;pdfaExtension:schemas&lt;/code&gt; block. Factur-X defines the namespace &lt;code&gt;urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#&lt;/code&gt; with four properties: &lt;code&gt;fx:DocumentType&lt;/code&gt; (&lt;code&gt;INVOICE&lt;/code&gt;), &lt;code&gt;fx:DocumentFileName&lt;/code&gt; (&lt;code&gt;factur-x.xml&lt;/code&gt;), &lt;code&gt;fx:Version&lt;/code&gt; (&lt;code&gt;1.0&lt;/code&gt;) and &lt;code&gt;fx:ConformanceLevel&lt;/code&gt;. The last one is spelled per profile, and the spelling is not your API's key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API profile&lt;/th&gt;
&lt;th&gt;&lt;code&gt;fx:ConformanceLevel&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;AFRelationship&lt;/code&gt; (German rule)&lt;/th&gt;
&lt;th&gt;Complete legal invoice?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minimum&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MINIMUM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;basicwl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BASIC WL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;basic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BASIC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Alternative&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;en16931&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EN 16931&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Alternative&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;extended&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXTENDED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Alternative&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the spaces in &lt;code&gt;BASIC WL&lt;/code&gt; and &lt;code&gt;EN 16931&lt;/code&gt;. A misspelled level is catchable: Mustang's &lt;code&gt;PDFValidator&lt;/code&gt; matches the XMP value against a fixed list — as of Mustang 2.26 that is &lt;code&gt;BASIC WL&lt;/code&gt;, &lt;code&gt;BASIC&lt;/code&gt;, &lt;code&gt;MINIMUM&lt;/code&gt;, &lt;code&gt;EN 16931&lt;/code&gt;, &lt;code&gt;COMFORT&lt;/code&gt;, &lt;code&gt;CIUS&lt;/code&gt;, &lt;code&gt;EXTENDED&lt;/code&gt;, &lt;code&gt;XRECHNUNG&lt;/code&gt;, a superset of the five Factur-X levels above — exactly and case-sensitively, and anything outside it is reported as &lt;code&gt;&amp;lt;error type="12"&amp;gt;XMP Metadata: ConformanceLevel contains invalid value&amp;lt;/error&amp;gt;&lt;/code&gt;. A level that is missing entirely raises &lt;code&gt;type="11"&lt;/code&gt; ("ConformanceLevel not found") and, because the membership check then fails too, &lt;code&gt;type="12"&lt;/code&gt; alongside it — &lt;a href="https://github.com/ZUGFeRD/mustangproject/blob/master/validator/src/main/java/org/mustangproject/validator/PDFValidator.java" rel="noopener noreferrer"&gt;validator source&lt;/a&gt;. What it does not catch is a level that is on the list but disagrees with the profile in the XML: the XML's own profile is read separately, and nothing in the validator compares the two, so &lt;code&gt;BASIC&lt;/code&gt; in the XMP on an EN 16931 invoice draws no complaint. Keep the mapping in exactly one place in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Four steps, and the order is load-bearing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt; the HTML with Chromium (Playwright &lt;code&gt;page.pdf()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalise&lt;/strong&gt; to PDF/A-3b with pikepdf: add the OutputIntent, set the &lt;code&gt;pdfaid&lt;/code&gt; XMP properties. Anything you stamp onto the page (we stamp a demo watermark on anonymous sandbox output) has to happen before this step, or its font is not part of the file the PDF/A check sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed&lt;/strong&gt; the XML with &lt;code&gt;factur-x&lt;/code&gt;, passing &lt;code&gt;afrelationship&lt;/code&gt; explicitly. The library copies the OutputIntent from its input and does not make a PDF/A itself, so step 2 must come first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate&lt;/strong&gt; with two tools: veraPDF with the &lt;code&gt;3b&lt;/code&gt; profile for the container, Mustang for the Factur-X structure and the XML.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Mustang gotcha: &lt;strong&gt;do not gate on its exit code.&lt;/strong&gt; In &lt;a href="https://github.com/ZUGFeRD/mustangproject/blob/master/validator/src/main/java/org/mustangproject/validator/ZUGFeRDValidator.java" rel="noopener noreferrer"&gt;the validator source&lt;/a&gt;, the flag that decides overall validity is set from the XML result alone (&lt;code&gt;wasCompletelyValid = xmlValidity&lt;/code&gt;); the PDF/A half is written into the XML report but does not change the verdict. A file with a broken PDF/A container and a valid XML exits 0 — and the report's last &lt;code&gt;&amp;lt;summary status="..."&amp;gt;&lt;/code&gt; element is that same XML-only verdict, so reading it misses the same failures. The PDF half writes its own &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; inside the &lt;code&gt;&amp;lt;pdf&amp;gt;&lt;/code&gt; section, and that is where a broken container or a bad &lt;code&gt;fx:ConformanceLevel&lt;/code&gt; shows up. Parse the report and require every &lt;code&gt;&amp;lt;summary status="..."&amp;gt;&lt;/code&gt; element to say &lt;code&gt;valid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We rejected two alternatives on purpose. Ghostscript's &lt;code&gt;pdfwrite&lt;/code&gt; can produce PDF/A, but it is AGPL, which for a closed-source service means a commercial licence, and we did not want to debug its output against veraPDF as well. If you are open source or already licensed, it is a legitimate path. And we do not run Schematron (the business rules of EN 16931) at request time: that needs a Java toolchain in the hot path. We validate against the profile XSD per request and run Mustang in the release gate. Schema-valid is not tax-compliant; the content stays the sender's responsibility either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps we hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;XML in a queue message.&lt;/strong&gt; Our workers pull jobs from SQS, which caps a message at 256 KB. A 200 KB invoice XML grows past that once it is encrypted and base64-encoded, and the failure would land after the job row and the quota charge. The fix is gzip before encrypt, then a size check of the final serialised message before anything is charged. How much you save depends almost entirely on how repetitive the invoice is: the small single-page profile samples we measured gzip only 3–6x, while an invoice with hundreds of line items does far better. So do not size the check from a ratio at all — measure the final serialised message, and note that the envelope base64-encodes twice, once around the gzip bytes and once around the AES ciphertext, inflating them by about 1.8x before they ever reach the queue. On the consumer side, cap the inflated size again (we stop at 1 MB) so a corrupted message cannot become a zip bomb on your own data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS watermarks do not survive customer CSS.&lt;/strong&gt; For the anonymous sandbox we mark the output. A &lt;code&gt;body::after&lt;/code&gt; watermark is removed by one &lt;code&gt;!important&lt;/code&gt; in the customer's stylesheet, because in the "bring your own HTML" mode the page is theirs. The stamp is a PDF-level overlay drawn with an embedded TrueType font, applied before PDF/A normalisation. Paid or free accounts get no stamp at all; this is only about anonymous output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;libxml2's schema registry is process-global.&lt;/strong&gt; On a freshly started worker, the first batch of jobs compiled the same XSDs in five threads at once, under Chromium memory pressure. libxml2's built-in type registry ended up corrupted for the life of the process, and every later job failed with &lt;code&gt;xmlSchemaPValAttrNodeValue, the given type is not a built-in type&lt;/code&gt;. It does not reproduce in isolation. &lt;code&gt;facturx.xml_check_xsd&lt;/code&gt; rebuilds the schema on every call, so we stopped using it at request time: compile each profile's schema once under a process-wide lock, warm all five up at startup so a broken environment fails the start instead of the customers' jobs, serialise validation through the same lock (it takes milliseconds; the compile is what costs), and exit the process when that error signature appears so the orchestrator restarts it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Profile mismatch.&lt;/strong&gt; The XML declares its own profile in &lt;code&gt;GuidelineSpecifiedDocumentContextParameter/ID&lt;/code&gt; (&lt;code&gt;urn:cen.eu:en16931:2017&lt;/code&gt; for EN 16931). If the caller says &lt;code&gt;en16931&lt;/code&gt; and the XML says &lt;code&gt;basic&lt;/code&gt;, do not pick one: reject. A silent upgrade produces a file whose XMP claims a level the XML does not meet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pypdf pins.&lt;/strong&gt; &lt;code&gt;factur-x&lt;/code&gt; needs pypdf 5.3 or newer. Our worker was pinned to 4.2, and the unit tests stub pypdf, so the pin change was green in CI and only a real file proved it. If your tests mock the PDF library, one live render belongs in the gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The self-hosted route
&lt;/h2&gt;

&lt;p&gt;Three stacks, one shape: get a PDF/A-3, then embed. All three snippets below were run as written on 2026-09-08 — the Python script end to end, the Java fragment compiled and executed against Mustang 2.26.0, the PHP one against horstoeko/zugferd v1.0.132 — and each output passed veraPDF (PDF/A-3b) and Mustang validation. They are still snippets, not products: error handling and your invoice data are on you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python: Playwright + pikepdf + factur-x&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One installation gotcha: on PyPI the package is &lt;code&gt;factur-x&lt;/code&gt; with a hyphen — &lt;code&gt;pip install facturx&lt;/code&gt; finds nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;factur-x pikepdf playwright &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Pdf&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;facturx&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;generate_from_binary&lt;/span&gt;

&lt;span class="n"&gt;AFRELATIONSHIP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;basicwl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;basic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alternative&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en16931&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alternative&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extended&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alternative&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;networkidle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;print_background&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;to_pdfa3b&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;icc_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;icc_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;icc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;icc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutputIntent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/GTS_PDFA1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;OutputConditionIdentifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pikepdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sRGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pikepdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sRGB IEC61966-2.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;DestOutputProfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make_indirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;icc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutputIntents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make_indirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;set_pikepdf_as_editor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdfaid:part&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdfaid:conformance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dc:title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en16931&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;xml_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;pdfa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;to_pdfa3b&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sRGB.icc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invoice INV-2026-0042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_from_binary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xml_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flavor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;factur-x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="n"&gt;afrelationship&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AFRELATIONSHIP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice-facturx.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship an sRGB ICC file in your image; pikepdf does not bundle one. Pin &lt;code&gt;factur-x&lt;/code&gt; deliberately: the XSD set it bundles defines what you accept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Java: Mustang&lt;/strong&gt; (run and validated 2026-09-08; artifact &lt;a href="https://central.sonatype.com/artifact/org.mustangproject/library" rel="noopener noreferrer"&gt;&lt;code&gt;org.mustangproject:library&lt;/code&gt;&lt;/a&gt; — 2.26.0 was the current release when this was written on 2026-09-07; check Maven Central for a newer 2.x)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.mustangproject&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;library&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;2.26.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA3&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.file.Files&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.file.Path&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readAllBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice.xml"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;ZUGFeRDExporterFromA3&lt;/span&gt; &lt;span class="n"&gt;exporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ZUGFeRDExporterFromA3&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProducer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"my-billing"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCreator&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"my-billing"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setProfile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"EN16931"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// no space here; Mustang writes "EN 16931" into the XMP itself&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-pdfa3.pdf"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;exporter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setXML&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;               &lt;span class="c1"&gt;// must come after setProfile: this call attaches the file&lt;/span&gt;
&lt;span class="n"&gt;exporter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;export&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice-facturx.pdf"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mustang writes the &lt;code&gt;factur-x.xml&lt;/code&gt; attachment and the &lt;code&gt;fx:&lt;/code&gt; XMP block for you, and its AFRelationship default is the opposite of the Python library's. In &lt;a href="https://github.com/ZUGFeRD/mustangproject/blob/master/library/src/main/java/org/mustangproject/ZUGFeRD/ZUGFeRDExporterFromA3.java" rel="noopener noreferrer"&gt;&lt;code&gt;ZUGFeRDExporterFromA3&lt;/code&gt;&lt;/a&gt; the relationship starts as &lt;code&gt;Alternative&lt;/code&gt; and is lowered to &lt;code&gt;Data&lt;/code&gt; only when the profile is MINIMUM or BASICWL — and only if you called &lt;code&gt;setProfile&lt;/code&gt; first, because the check reads that field. Skip the call and a MINIMUM invoice ships as &lt;code&gt;Alternative&lt;/code&gt;. Two more things the source says and the docs do not: &lt;code&gt;ignorePDFAErrors()&lt;/code&gt; compiles on this class but changes nothing, because &lt;code&gt;ensurePDFIsValid&lt;/code&gt; here returns &lt;code&gt;true&lt;/code&gt; unconditionally and &lt;code&gt;load()&lt;/code&gt; never inspects the input — it is the &lt;code&gt;...FromA1&lt;/code&gt; exporter that validates and throws. And its default conformance level is &lt;code&gt;UNICODE&lt;/code&gt; (&lt;code&gt;conformanceLevel = PDFAConformanceLevel.UNICODE&lt;/code&gt;, with &lt;code&gt;overwrite = true&lt;/code&gt;), so the output declares &lt;code&gt;pdfaid:conformance=U&lt;/code&gt; — PDF/A-3U, not 3b. &lt;code&gt;setConformanceLevel(PDFAConformanceLevel.BASIC)&lt;/code&gt; makes it declare &lt;code&gt;B&lt;/code&gt; instead; whichever letter you ship, validate against the level you claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PHP: horstoeko/zugferd&lt;/strong&gt; (run and validated 2026-09-08 against tag &lt;a href="https://github.com/horstoeko/zugferd/blob/v1.0.132/src/ZugferdDocumentPdfBuilderAbstract.php" rel="noopener noreferrer"&gt;v1.0.132&lt;/a&gt;; &lt;code&gt;composer require horstoeko/zugferd&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="s1"&gt;'vendor/autoload.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;horstoeko\zugferd\ZugferdDocumentPdfMerger&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$merger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ZugferdDocumentPdfMerger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/path/invoice.xml'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/path/invoice.pdf'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$merger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setAttachmentRelationshipTypeToAlternative&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// default is 'Data' — the same trap again&lt;/span&gt;
&lt;span class="nv"&gt;$merger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$merger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;saveDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/path/invoice-facturx.pdf'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This library repeats the Python default: &lt;code&gt;ZugferdDocumentPdfBuilderAbstract&lt;/code&gt; initialises the attachment relationship to &lt;code&gt;Data&lt;/code&gt;, so an EN 16931 invoice carries &lt;code&gt;Data&lt;/code&gt; unless you say otherwise. It reads the profile out of your XML rather than taking it as an argument, names the attachment from the resolved profile (&lt;code&gt;factur-x.xml&lt;/code&gt; for the Factur-X profiles, &lt;code&gt;xrechnung.xml&lt;/code&gt; for XRechnung), writes the &lt;code&gt;fx:&lt;/code&gt; block with the spaced &lt;code&gt;EN 16931&lt;/code&gt;, and emits its own sRGB OutputIntent — the pages of your input are re-imported into a fresh document (FPDI) and the PDF/A metadata is written from scratch, so the input is a plain PDF here, not a PDF/A-3. Whether the result passes veraPDF still depends on what is inside those pages, fonts first. Whatever your stack, run the checklist at the end on the output; the libraries agree on structure and disagree on defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hosted route (this is mine)
&lt;/h2&gt;

&lt;p&gt;PDFik's version of the same pipeline is &lt;code&gt;POST /einvoice-to-pdf&lt;/code&gt;: you send the CII XML, we build the visual invoice from a block template (or you keep your own HTML, see below), normalise to PDF/A-3, embed with the profile-correct relationship and metadata, and hand back a job id. The output is validated with veraPDF and Mustang in our release gate, on every plan including Free, with no watermark on account output. The Free plan takes no credit card to start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# invoice.xml is your UN/CEFACT CII file (EN 16931 profile)&lt;/span&gt;
jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--rawfile&lt;/span&gt; xml invoice.xml &lt;span class="s1"&gt;'{xml: $xml, profile: "en16931"}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; request.json

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.pdfik.net/einvoice-to-pdf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: sk_live_YOUR_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; @request.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is &lt;code&gt;202&lt;/code&gt; with &lt;code&gt;{"job_id": "...", "status": "queued", ...}&lt;/code&gt;; poll &lt;code&gt;GET /jobs/{job_id}&lt;/code&gt; and download from &lt;code&gt;GET /jobs/{job_id}/download&lt;/code&gt;, or give a &lt;code&gt;webhook_url&lt;/code&gt;. &lt;code&gt;profile&lt;/code&gt; defaults to &lt;code&gt;en16931&lt;/code&gt;. The XML is validated against the profile's XSD, its guideline URN must match the declared profile, and a rejected request costs nothing. Up to 1 MB of XML.&lt;/p&gt;

&lt;p&gt;To keep your own design, add an &lt;code&gt;einvoice&lt;/code&gt; block to &lt;code&gt;POST /html-to-pdf&lt;/code&gt; or &lt;code&gt;POST /url-to-pdf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;html&amp;gt;...your invoice...&amp;lt;/html&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"einvoice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"factur-x"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en16931"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"xml"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;rsm:CrossIndustryInvoice ..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It cannot be combined with &lt;code&gt;user_password&lt;/code&gt; (PDF/A forbids encryption) or &lt;code&gt;compression&lt;/code&gt; (re-saving breaks the PDF/A attributes); both are rejected with &lt;code&gt;422&lt;/code&gt;. The full reference is at &lt;a href="https://docs.pdfik.net/einvoicing" rel="noopener noreferrer"&gt;docs.pdfik.net/einvoicing&lt;/a&gt;, and the homepage sandbox runs the same pipeline on pasted XML without an account, watermarked, a few renders per day.&lt;/p&gt;

&lt;p&gt;When the hosted route is the wrong choice: if you are air-gapped or your policy requires documents to never leave your own infrastructure, self-host. Same answer if you already operate Mustang in a Java stack, or if your volume makes per-document pricing lose to your own servers. It also accepts CII only; UBL input is not supported today. (Since September 2026 PDFik runs on AWS in the EU — Frankfurt, Germany — so invoice data is processed and stored in the EU.)&lt;/p&gt;

&lt;h2&gt;
  
  
  A validation checklist you can run
&lt;/h2&gt;

&lt;p&gt;Run all four on every build, not once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. PDF/A-3b container — assert the value; a missing line must FAIL, not print nothing&lt;/span&gt;
verapdf &lt;span class="nt"&gt;-f&lt;/span&gt; 3b &lt;span class="nt"&gt;--format&lt;/span&gt; xml invoice-facturx.pdf &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'isCompliant="true"'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PDF/A OK"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PDF/A FAIL"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 2. Factur-X structure + XML; never the exit code — and never only the LAST&lt;/span&gt;
&lt;span class="c"&gt;#    summary: both come from the XML half alone (wasCompletelyValid = xmlValidity),&lt;/span&gt;
&lt;span class="c"&gt;#    so a broken PDF/A half or a bad ConformanceLevel only turns the &amp;lt;pdf&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;#    section's own &amp;lt;summary&amp;gt; invalid while the last one stays "valid". Require&lt;/span&gt;
&lt;span class="c"&gt;#    EVERY summary to be valid, and assert at least one exists (Mustang&lt;/span&gt;
&lt;span class="c"&gt;#    crashed or the report format changed = no match = FAIL)&lt;/span&gt;
&lt;span class="nv"&gt;report&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;java &lt;span class="nt"&gt;-jar&lt;/span&gt; Mustang-CLI.jar &lt;span class="nt"&gt;--no-notice&lt;/span&gt; &lt;span class="nt"&gt;--action&lt;/span&gt; validate &lt;span class="nt"&gt;--source&lt;/span&gt; invoice-facturx.pdf&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;summary status='&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$report&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;summary status="[a-z]*"'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qv&lt;/span&gt; &lt;span class="s1"&gt;'status="valid"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Factur-X OK"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Factur-X FAIL"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 3 + 4 + 5. The things the validators do not check for you
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt;

&lt;span class="n"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice-facturx.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/AFRelationship&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# ['/Alternative'] for en16931
&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmbeddedFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Names&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;           &lt;span class="c1"&gt;# ['factur-x.xml']
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_metadata&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;xmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fx:ConformanceLevel[^&amp;lt;]*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;              &lt;span class="c1"&gt;# 'EN 16931', with the space
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdfaid:part&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;xmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/OutputIntents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 5. The binding no validator performs: the XMP level must agree with the XML's
#    own guideline URN. Mustang reads both but never compares them.
&lt;/span&gt;&lt;span class="n"&gt;URN_TO_LEVEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:factur-x.eu:1p0:minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MINIMUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:factur-x.eu:1p0:basicwl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BASIC WL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BASIC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:cen.eu:en16931:2017&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EN 16931&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXTENDED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;factur-x.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get_file&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;urn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Guideline\w*DocumentContextParameter&amp;gt;\s*&amp;lt;[^&amp;gt;]*ID[^&amp;gt;]*&amp;gt;([^&amp;lt;]+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fx:ConformanceLevel(?:&amp;gt;|=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;)([^&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;URN_TO_LEVEL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;urn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the boring ones: &lt;code&gt;TypeCode&lt;/code&gt; is 380 or 381; the PDF shows every amount the XML carries (otherwise &lt;code&gt;Alternative&lt;/code&gt; is a lie); the file opens in Adobe Reader with the attachment visible; and a wrong profile, a DTD in the XML, and an unembedded font each fail your pipeline loudly instead of producing a file.&lt;/p&gt;

&lt;p&gt;One more trap, raised in the comments: the business-rule layer has the same versioning problem as the container layer. XRechnung Schematron packs from KoSIT are date-gated — in a transition window two versions are in force at once, so an invoice can pass the current pack while failing the one that actually applied on its issue date, and nothing in the PDF layer catches that. This checklist deliberately stops at the container and the structural XML; Schematron business rules stay with the receiving side. If you do validate them yourself, pin every Schematron pack and codelist by version.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Library defaults, validator behaviour and mandate dates last verified: 2026-09-07 — factur-x 6.8, Mustang 2.26.0 (library and validator sources), horstoeko/zugferd v1.0.132, and the BMF, DGFiP and KSeF pages linked above. Checklist tightened 2026-09-18 after reader feedback and a full re-verification of every block in this article (each one executed, plus the failure directions): the shell checks now assert their values, the Mustang gate requires every &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; in the report to be valid (the last one alone is the XML-only verdict and misses PDF-half failures — proven live on Mustang 2.26.0), and the XMP-level-vs-guideline-URN binding moved from prose into code. If something here is outdated, tell me and I'll fix it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>python</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>wkhtmltopdf in Docker in 2026: musl, libssl1.1, and the ways out</title>
      <dc:creator>Vitalii Nemudryi</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:24:16 +0000</pubDate>
      <link>https://dev.to/pdfik/wkhtmltopdf-in-docker-in-2026-musl-libssl11-and-the-ways-out-3edm</link>
      <guid>https://dev.to/pdfik/wkhtmltopdf-in-docker-in-2026-musl-libssl11-and-the-ways-out-3edm</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I'm Vitalii, founder of &lt;a href="https://pdfik.net" rel="noopener noreferrer"&gt;PDFik&lt;/a&gt;, a hosted URL/HTML-to-PDF API. It shows up once near the end, clearly marked. The rest of this is the debugging guide I wish existed the last three times someone hit these errors.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you run &lt;code&gt;wkhtmltopdf&lt;/code&gt; in containers, you have probably met at least one of these three errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;sh: /usr/local/bin/wkhtmltopdf: not found        #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Alpine
&lt;span class="go"&gt;wkhtmltox : Depends: libssl1.1 but it is not installable
&lt;/span&gt;&lt;span class="gp"&gt;E: Unable to locate package wkhtmltopdf          #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Ubuntu 24.04 / Debian 13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three have the same root cause: &lt;strong&gt;the project is archived&lt;/strong&gt; (January 2023, &lt;a href="https://github.com/wkhtmltopdf/wkhtmltopdf" rel="noopener noreferrer"&gt;repository read-only&lt;/a&gt;) and the last official packages were built in &lt;strong&gt;May 2023&lt;/strong&gt; — release &lt;a href="https://github.com/wkhtmltopdf/packaging/releases" rel="noopener noreferrer"&gt;0.12.6.1-3&lt;/a&gt;, whose newest targets are Debian 12 (bookworm) and Ubuntu 22.04 (jammy). The distros kept moving; the binaries stopped. Here is what each error actually means, the recipe that still works in 2026, and the honest exits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error 1: &lt;code&gt;not found&lt;/code&gt; on Alpine — it's not about PATH
&lt;/h2&gt;

&lt;p&gt;The confusing part: the file &lt;em&gt;is&lt;/em&gt; there, &lt;code&gt;ls&lt;/code&gt; sees it, and the shell still says &lt;code&gt;not found&lt;/code&gt;. That message comes from the kernel failing to load the binary's interpreter: official wkhtmltopdf builds link against &lt;strong&gt;glibc&lt;/strong&gt;, Alpine ships &lt;strong&gt;musl&lt;/strong&gt;, and the referenced dynamic loader (&lt;code&gt;/lib64/ld-linux-x86-64.so.2&lt;/code&gt;) does not exist on Alpine. &lt;code&gt;ldd /usr/local/bin/wkhtmltopdf&lt;/code&gt; shows it immediately.&lt;/p&gt;

&lt;p&gt;There is no supported way around it on Alpine today: the distro dropped its &lt;code&gt;wkhtmltopdf&lt;/code&gt; package years ago (nothing in current stable), and &lt;code&gt;gcompat&lt;/code&gt; shims are a lottery with a binary this large. If the container must run wkhtmltopdf, don't build it on Alpine — that fight is not worth the ~50 MB you save.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error 2: &lt;code&gt;Depends: libssl1.1&lt;/code&gt; — you're installing a 2020 build on a 2023+ distro
&lt;/h2&gt;

&lt;p&gt;The widely-copied Dockerfiles fetch &lt;code&gt;wkhtmltox_0.12.6-1.*.deb&lt;/code&gt;, which links OpenSSL 1.1. Debian 12, Ubuntu 22.04+ and everything after ship OpenSSL 3 and removed &lt;code&gt;libssl1.1&lt;/code&gt; from the archives, so the dependency is unresolvable. (Pinning an EOL base image or hand-installing an EOL libssl to work around it means running an archived renderer on top of an unpatched TLS stack — please don't.)&lt;/p&gt;

&lt;p&gt;The fix is simply the last release, which was built against OpenSSL 3 for bookworm and jammy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recipe that still works in 2026
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; debian:bookworm-slim&lt;/span&gt;

&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; WKHTML_VERSION=0.12.6.1-3&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-eux&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;arch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get update&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; curl ca-certificates&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    curl &lt;span class="nt"&gt;-fsSLo&lt;/span&gt; /tmp/wkhtmltox.deb &lt;span class="se"&gt;\
&lt;/span&gt;      &lt;span class="s2"&gt;"https://github.com/wkhtmltopdf/packaging/releases/download/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WKHTML_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/wkhtmltox_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WKHTML_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.bookworm_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;arch&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.deb"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; /tmp/wkhtmltox.deb&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/wkhtmltox.deb&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Fonts are on you: the image has almost none, and missing glyphs render as tofu.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;      fontconfig fonts-dejavu-core fonts-noto-core fonts-noto-cjk &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes that save an afternoon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dpkg --print-architecture&lt;/code&gt; makes the same Dockerfile work on amd64 and arm64 — 0.12.6.1-3 ships both for bookworm and jammy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fonts&lt;/strong&gt;: wkhtmltopdf uses system fonts via fontconfig. Add the font packages your documents actually need (the Noto families cover most scripts); re-check any non-Latin text after every base-image change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt;: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2022-35583" rel="noopener noreferrer"&gt;CVE-2022-35583&lt;/a&gt; (SSRF through rendered content) is permanently unfixed. Never feed wkhtmltopdf untrusted HTML, and treat the container as hostile: no metadata endpoint access, egress restricted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Error 3: Ubuntu 24.04, Debian 13, next year's anything
&lt;/h2&gt;

&lt;p&gt;There is no build for noble, trixie, or anything newer — and there never will be. The bookworm/jammy debs may keep installing on newer bases for a while if the dependency chain happens to align, but every base-image bump is now a gamble against a binary from 2023. This is the actual signal to plan the migration rather than the next workaround.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exits, briefly
&lt;/h2&gt;

&lt;p&gt;I wrote a full decision tree in &lt;a href="https://dev.to/pdfik/wkhtmltopdf-is-archived-with-an-open-cve-a-field-guide-to-moving-off-it-284a"&gt;the migration field guide&lt;/a&gt; (including where each alternative beats everything else); the short version for container people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Headless Chromium in your stack&lt;/strong&gt; — Puppeteer/Playwright (&lt;code&gt;page.pdf()&lt;/code&gt;), or &lt;a href="https://gotenberg.dev/" rel="noopener noreferrer"&gt;Gotenberg&lt;/a&gt; as a ready-made sidecar container when you want an HTTP API without writing the browser management yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://weasyprint.org/" rel="noopener noreferrer"&gt;WeasyPrint&lt;/a&gt;&lt;/strong&gt; — if your documents are print-CSS shaped (invoices, statements) and don't run JavaScript; no browser process at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hosted rendering API&lt;/strong&gt; — when you'd rather ship an HTTP call than a browser fleet. Disclosure: this category includes my product, &lt;a href="https://pdfik.net" rel="noopener noreferrer"&gt;PDFik&lt;/a&gt;. If the thing keeping you on wkhtmltopdf is the &lt;em&gt;flags&lt;/em&gt; — scripts and wrappers that speak its CLI — the PDFik CLI has a compatibility mode that takes wkhtmltopdf's own command line, and its image is a few megabytes with no browser inside:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; PDFIK_API_KEY &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/work"&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /work &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/pdfik/cli wkhtmltopdf &lt;span class="nt"&gt;-s&lt;/span&gt; A4 &lt;span class="nt"&gt;--footer-center&lt;/span&gt; &lt;span class="s1"&gt;'Page [page] of [topage]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com report.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every flag is mapped, accepted-with-a-warning, or refused with a reason — never silently ignored (&lt;a href="https://github.com/pdfik/cli/blob/main/COMPATIBILITY.md" rel="noopener noreferrer"&gt;flag-by-flag tables&lt;/a&gt;, &lt;a href="https://pdfik.net/wkhtmltopdf-alternative" rel="noopener noreferrer"&gt;option mapping&lt;/a&gt;). Rendering happens in our cloud on sandboxed Chromium, so the same honest caveat applies as to every hosted option: wrong for air-gapped setups, and your golden files need re-approval because the engine is not WebKit.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Package availability, archive status and CVE status last verified: 2026-08-27. If something here has rotted, tell me and I'll fix it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>pdf</category>
      <category>devops</category>
      <category>linux</category>
    </item>
    <item>
      <title>wkhtmltopdf is archived with an open CVE — a field guide to moving off it</title>
      <dc:creator>Vitalii Nemudryi</dc:creator>
      <pubDate>Wed, 26 Aug 2026 16:25:33 +0000</pubDate>
      <link>https://dev.to/pdfik/wkhtmltopdf-is-archived-with-an-open-cve-a-field-guide-to-moving-off-it-284a</link>
      <guid>https://dev.to/pdfik/wkhtmltopdf-is-archived-with-an-open-cve-a-field-guide-to-moving-off-it-284a</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I'm Vitalii, founder of &lt;a href="https://pdfik.net" rel="noopener noreferrer"&gt;PDFik&lt;/a&gt;, a hosted&lt;br&gt;
URL/HTML-to-PDF API. It appears below as one option among several — including the ones that&lt;br&gt;
compete with it. Where a competitor is the better fit, I say so.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a decade, &lt;code&gt;wkhtmltopdf&lt;/code&gt; was the default answer to "how do I turn HTML into a PDF from my&lt;br&gt;
backend?" — one static binary, no browser to babysit, a flag for everything. Most invoice,&lt;br&gt;
report and ticket generators older than five years have it somewhere in their dependency tree.&lt;/p&gt;

&lt;p&gt;Three facts changed that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The project is archived.&lt;/strong&gt; The GitHub repository was
&lt;a href="https://github.com/wkhtmltopdf/wkhtmltopdf" rel="noopener noreferrer"&gt;archived on January 2, 2023&lt;/a&gt; and is read-only:
no maintainers, no releases, no security patches — ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is an unpatched vulnerability.&lt;/strong&gt;
&lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2022-35583" rel="noopener noreferrer"&gt;CVE-2022-35583&lt;/a&gt; (SSRF via rendered
content) will never be fixed upstream. If your scanner flags it, there is no "upgrade to
version X" remediation — the remediation is migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The packaging is rotting.&lt;/strong&gt; The engine is a patched Qt WebKit from another era: modern
CSS (grid, flexbox gaps, custom properties) silently misrenders, distros have been dropping
the package, official builds don't cover current Debian/Ubuntu releases or arm64 well, and
Docker images increasingly rely on third-party rebuilds of an unmaintained binary.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this means your PDFs stop rendering tomorrow. It means every new deployment target,&lt;br&gt;
every security audit, and every CSS feature your designers use widens the gap. Here is the&lt;br&gt;
decision tree I'd use to close it.&lt;/p&gt;
&lt;h2&gt;
  
  
  First, inventory what wkhtmltopdf actually does for you
&lt;/h2&gt;

&lt;p&gt;Before comparing engines, grep your codebase for the flags you really use. In practice most&lt;br&gt;
setups boil down to a handful:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You use&lt;/th&gt;
&lt;th&gt;You need from a replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--header-html&lt;/code&gt; / &lt;code&gt;--footer-html&lt;/code&gt;, page numbers&lt;/td&gt;
&lt;td&gt;header/footer templates with page variables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--margin-*&lt;/code&gt;, &lt;code&gt;--orientation&lt;/code&gt;, &lt;code&gt;--page-size&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;standard page options (every option below has these)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--print-media-type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;print-CSS support (&lt;code&gt;@page&lt;/code&gt;, &lt;code&gt;page-break-*&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--javascript-delay&lt;/code&gt;, &lt;code&gt;--window-status&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;an explicit "wait until ready" mechanism&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--cookie&lt;/code&gt;, &lt;code&gt;--custom-header&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;authenticated fetching of the source page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--toc&lt;/code&gt;, &lt;code&gt;--outline&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;PDF outline/bookmarks — &lt;strong&gt;check carefully, this is the weakest spot everywhere&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stdin/stdout piping in a worker&lt;/td&gt;
&lt;td&gt;either a library call or an API client&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two honest warnings apply to &lt;em&gt;every&lt;/em&gt; path below: &lt;strong&gt;no Chromium-based engine reproduces&lt;br&gt;
wkhtmltopdf output pixel-for-pixel&lt;/strong&gt; (different engine, different line breaking, different&lt;br&gt;
font fallback — you will re-approve golden files), and &lt;strong&gt;&lt;code&gt;--toc&lt;/code&gt;/&lt;code&gt;--outline&lt;/code&gt; have no&lt;br&gt;
first-class Chromium equivalent&lt;/strong&gt; (you rebuild outlines with a post-processing step or live&lt;br&gt;
without them).&lt;/p&gt;
&lt;h2&gt;
  
  
  Path 1 — in-process library: WeasyPrint
&lt;/h2&gt;

&lt;p&gt;If your documents are print-oriented (invoices, contracts, statements), your templates don't&lt;br&gt;
run JavaScript, and you're on Python — &lt;a href="https://weasyprint.org/" rel="noopener noreferrer"&gt;WeasyPrint&lt;/a&gt; is the closest&lt;br&gt;
thing to a spiritual successor: a real &lt;code&gt;@page&lt;/code&gt;-first CSS engine, no browser process at all,&lt;br&gt;
excellent page-break control, actively maintained.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose it when:&lt;/strong&gt; print CSS is enough, no JS charts, Python stack, documents must not
leave your infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk away when:&lt;/strong&gt; templates depend on JavaScript rendering (Chart.js, React-rendered
markup) or you need the page to look exactly like it does in a browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The commercial sibling of this category is PrinceXML (and &lt;a href="https://docraptor.com/" rel="noopener noreferrer"&gt;DocRaptor&lt;/a&gt;,&lt;br&gt;
the hosted API built on it) — the strongest print-CSS engine on the market and the reference&lt;br&gt;
choice when compliance-grade, print-perfect documents justify the price.&lt;/p&gt;
&lt;h2&gt;
  
  
  Path 2 — DIY headless Chromium: Playwright or Puppeteer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;page.pdf()&lt;/code&gt; in &lt;a href="https://playwright.dev/" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt; / &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;Puppeteer&lt;/a&gt; gives&lt;br&gt;
you a modern engine, full JS execution, and total control. The code is five lines; the&lt;br&gt;
operations are not: you now run a browser fleet. Zombie processes, memory ceilings per tab,&lt;br&gt;
crash-looping renderers under load, sandboxing (seccomp/user namespaces if you take isolation&lt;br&gt;
seriously), font packages in the image, timeouts, and scaling the pool — all yours.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose it when:&lt;/strong&gt; you already operate containers comfortably, render volume is modest or
bursty-but-internal, and you want zero per-document vendor cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk away when:&lt;/strong&gt; PDF generation is a side feature and you'd rather not own a browser
farm's pager duty.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Path 3 — self-hosted rendering API: Gotenberg
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://gotenberg.dev/" rel="noopener noreferrer"&gt;Gotenberg&lt;/a&gt; wraps Chromium (and LibreOffice for office formats) in a&lt;br&gt;
Docker container with a clean HTTP API. You keep data on your infrastructure and get out of&lt;br&gt;
the "manage Playwright yourself" business; you still own capacity planning, upgrades and&lt;br&gt;
availability.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose it when:&lt;/strong&gt; documents must stay in your VPC but you want an API, not a library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk away when:&lt;/strong&gt; you don't want to run and scale the container at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Path 4 — hosted APIs
&lt;/h2&gt;

&lt;p&gt;If rendering is not your core business, a hosted API turns the whole problem into an HTTP&lt;br&gt;
call. The market is healthy — a few honest reference points, all with published pricing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docraptor.com/" rel="noopener noreferrer"&gt;DocRaptor&lt;/a&gt;&lt;/strong&gt; — PrinceXML engine, the print-CSS gold standard,
SOC 2 / HIPAA-BAA posture; documents can be very large. The established choice for
compliance-heavy document generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://pdfshift.io/" rel="noopener noreferrer"&gt;PDFShift&lt;/a&gt;, &lt;a href="https://www.api2pdf.com/" rel="noopener noreferrer"&gt;Api2Pdf&lt;/a&gt;,
&lt;a href="https://www.pdfmonkey.io/" rel="noopener noreferrer"&gt;PDFMonkey&lt;/a&gt;&lt;/strong&gt; and others — Chromium-based hosted rendering with
different pricing shapes (per-document, per-credit, template-first workflows). Worth
shortlisting all three; which wins depends on your volume curve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://pdfik.net" rel="noopener noreferrer"&gt;PDFik&lt;/a&gt;&lt;/strong&gt; — my product, so discount accordingly. The design bet is
async-first: you &lt;code&gt;POST&lt;/code&gt; a URL or HTML, get a &lt;code&gt;job_id&lt;/code&gt;, and receive an HMAC-signed webhook
when the file is ready (polling and downloads exist too), with a free test mode that runs
the full pipeline without touching your quota. There's a
&lt;a href="https://pdfik.net/wkhtmltopdf-alternative" rel="noopener noreferrer"&gt;flag-by-flag wkhtmltopdf migration map&lt;/a&gt; that
covers the table above in detail — including the places where wkhtmltopdf still wins.
If what you actually want is to keep your existing wkhtmltopdf command lines, see
path 5 below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When is a hosted API simply wrong? Air-gapped environments, documents that legally may not&lt;br&gt;
leave your infrastructure, or rendering volumes so high that per-document pricing can't beat&lt;br&gt;
your marginal server cost. Those cases belong to paths 1–3.&lt;/p&gt;
&lt;h2&gt;
  
  
  Path 5 — keep the wkhtmltopdf interface, swap the engine: &lt;code&gt;pdfik wkhtmltopdf&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Full disclosure again: this one is mine too. Sometimes the blocker is not the engine but&lt;br&gt;
the &lt;em&gt;interface&lt;/em&gt; — hundreds of lines of shell, cron jobs and wrappers (pdfkit, wicked_pdf)&lt;br&gt;
that all speak wkhtmltopdf's flags, and nobody wants to rewrite them just to retire a&lt;br&gt;
binary. The &lt;a href="https://github.com/pdfik/cli" rel="noopener noreferrer"&gt;PDFik CLI&lt;/a&gt; ships a compatibility mode that&lt;br&gt;
takes wkhtmltopdf's own command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;wkhtmltopdf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'pdfik wkhtmltopdf'&lt;/span&gt;
wkhtmltopdf &lt;span class="nt"&gt;-s&lt;/span&gt; A4 &lt;span class="nt"&gt;-O&lt;/span&gt; Landscape &lt;span class="nt"&gt;--footer-center&lt;/span&gt; &lt;span class="s1"&gt;'Page [page] of [topage]'&lt;/span&gt; https://example.com out.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every wkhtmltopdf flag is either mapped to the API, accepted with a warning (it has no&lt;br&gt;
effect in this pipeline), or refused with a reason (it would silently change your output)&lt;br&gt;
— never ignored. &lt;code&gt;--version&lt;/code&gt; and &lt;code&gt;-h&lt;/code&gt; answer the way wrappers expect, so pdfkit and&lt;br&gt;
wicked_pdf keep working unmodified. The CLI itself is one static binary&lt;br&gt;
(Linux/macOS/Windows, MIT, open source), also on Docker as &lt;code&gt;ghcr.io/pdfik/cli&lt;/code&gt;; the&lt;br&gt;
flag-by-flag tables live in the repo's&lt;br&gt;
&lt;a href="https://github.com/pdfik/cli/blob/main/COMPATIBILITY.md" rel="noopener noreferrer"&gt;COMPATIBILITY.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Be clear about what this is: rendering happens in PDFik's cloud, so this is path 4 wearing&lt;br&gt;
a familiar face — you need network access and an API key, and it is wrong for air-gapped&lt;br&gt;
environments for the same reasons. The engine is sandboxed Chromium, not WebKit, so the&lt;br&gt;
golden-files warning above applies in full. And &lt;code&gt;--toc&lt;/code&gt;/&lt;code&gt;--outline&lt;/code&gt; are refused, not&lt;br&gt;
emulated — the honest answer from the inventory table stands here too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose it when:&lt;/strong&gt; the wkhtmltopdf &lt;em&gt;interface&lt;/em&gt; is load-bearing (scripts, wrappers,
colleagues' muscle memory) and hosted rendering is acceptable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk away when:&lt;/strong&gt; documents may not leave your infrastructure — that is paths 1–3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The migration checklist (whatever you pick)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Golden files first.&lt;/strong&gt; Render your 10 ugliest real documents on the old and new engine,
diff visually, and get sign-off &lt;em&gt;before&lt;/em&gt; touching production code paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fonts.&lt;/strong&gt; wkhtmltopdf used system fonts; containers and APIs won't have them by accident.
Embed via &lt;code&gt;@font-face&lt;/code&gt; or install them explicitly, then re-check non-Latin text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page breaks.&lt;/strong&gt; Replace &lt;code&gt;page-break-*&lt;/code&gt; hacks tuned for WebKit with standard
&lt;code&gt;break-inside: avoid&lt;/code&gt; / &lt;code&gt;@page&lt;/code&gt; rules and re-test tables that span pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readiness signal.&lt;/strong&gt; Replace &lt;code&gt;--javascript-delay&lt;/code&gt; guesswork with an explicit wait
(a selector, &lt;code&gt;window-status&lt;/code&gt;-style flag, or your engine's network-idle event).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headers/footers.&lt;/strong&gt; Rebuild &lt;code&gt;--header-html&lt;/code&gt; as the engine's header/footer template and
re-verify page numbering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outline/TOC.&lt;/strong&gt; If you used &lt;code&gt;--toc&lt;/code&gt;, decide now: post-process the PDF to rebuild
bookmarks, or drop the feature consciously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts and size limits.&lt;/strong&gt; Async pipelines and APIs enforce both; find your P99 render
time and largest document before your users do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the old binary in CI&lt;/strong&gt; for one release as a fallback renderer behind a flag —
migrations get reverted for boring reasons.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  TL;DR decision tree
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Print-CSS documents, no JS, Python → &lt;strong&gt;WeasyPrint&lt;/strong&gt; (or PrinceXML/DocRaptor when budget
allows).&lt;/li&gt;
&lt;li&gt;Want full control and don't mind operating browsers → &lt;strong&gt;Playwright/Puppeteer&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Data must stay home, but you want an API → &lt;strong&gt;Gotenberg&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Want it to be someone else's pager → &lt;strong&gt;hosted API&lt;/strong&gt; (DocRaptor for print/compliance;
PDFShift/Api2Pdf/PDFMonkey/PDFik for Chromium-based rendering — pick by pricing shape and
workflow; mine is the async/webhook-first one).&lt;/li&gt;
&lt;li&gt;Scripts and wrappers full of wkhtmltopdf flags you'd rather not touch →
&lt;strong&gt;&lt;code&gt;pdfik wkhtmltopdf&lt;/code&gt;&lt;/strong&gt; (compatibility mode of my CLI — hosted rendering behind the
old interface).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Archive status and CVE status last verified: 2026-08-26. If something above is outdated,&lt;br&gt;
tell me and I'll fix it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>security</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
