Skip to content

- adds PDF/A support required for fracturex / zugferd - #145

Merged
Sharcoux merged 1 commit into
cantoo-scribe:masterfrom
FrauElster:master
Jul 30, 2026
Merged

- adds PDF/A support required for fracturex / zugferd#145
Sharcoux merged 1 commit into
cantoo-scribe:masterfrom
FrauElster:master

Conversation

@FrauElster

@FrauElster FrauElster commented Jul 28, 2026

Copy link
Copy Markdown

Disclaimer

I let Claude Opus write this and only reviewed it. I ran some of the integration tests, which looked as goofy as before. I did not run e.g. RN integration tests.

I let it generate a mock zugferd invoice and validated it agains this online checker, as well as verapdf as local checker.

This itself does not bring in fracturX / zugferd. It just adds the PDF/A stuff which is required by those.
trying to keep this PR focused, and therefore this is not added here, although it might be the biggest use case of PDF/A. I might add a second PR adding those once this one is merged.

What?

Adds first-class PDF/A support to pdf-lib via a new public method, PDFDocument.convertToPDFA(), plus the small building blocks it needs. This resolves the upstream request Hopding#1183, where the community has repeatedly asked for a built-in way to produce PDF/A / ZUGFeRD / Factur-X documents instead of hand-assembling the structures in userland.

import { PDFDocument } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';

const pdfDoc = await PDFDocument.create();
pdfDoc.registerFontkit(fontkit);

// PDF/A requires embedded fonts — the 14 standard fonts are NOT compliant.
const font = await pdfDoc.embedFont(customFontBytes, { subset: true });
pdfDoc.addPage().drawText('Hello PDF/A', { font });

pdfDoc.setTitle('Invoice 2026-0001');
pdfDoc.setAuthor('ACME GmbH');

// One call performs all the structural work PDF/A needs.
pdfDoc.convertToPDFA({ conformance: '3B' }); // '1B' | '2B' | '2U' | '3B' | '3U'

const pdfBytes = await pdfDoc.save();

New public API:

  • PDFDocument.convertToPDFA(options?: ConvertToPDFAOptions): void
  • ConvertToPDFAOptionsconformance (default '3B'), iccProfile (defaults to a bundled sRGB profile), outputConditionIdentifier, colorComponents.
  • PDFAConformanceLevel type + parseConformance() helper.
  • getDefaultSRGBProfile() — returns the bundled sRGB profile bytes.
  • buildPDFAMetadata() — builds the XMP packet (exported for advanced use / testing).

What convertToPDFA() does:

  • Adds a document /ID to the trailer (reuses an existing one if present).
  • Adds an OutputIntent (GTS_PDFA1) referencing an embedded ICC profile. A standards-compliant sRGB IEC61966-2.1 (ICC v2) profile is bundled, so it works out of the box; callers can pass their own.
  • Adds an uncompressed XMP /Metadata stream declaring the pdfaid part/conformance, kept consistent with the document information dictionary.
  • Sets the PDF header version (1.4 for part 1, 1.7 for parts 2/3) and, for PDF/A-1, ensures object/cross-reference streams are disabled on save.

Why?

PDF/A is the standard for long-term archiving and is the container format required by e-invoicing standards such as ZUGFeRD / Factur-X (PDF/A-3) — a recurring, high-demand ask (Hopding#1183, Hopding#230, Hopding#229). Until now users had to manually stitch together the /ID, OutputIntent, ICC profile, and XMP metadata, which is error-prone and hard to get past a validator. This makes the common case a single call while leaving the escape hatches (custom ICC profile, conformance level) open.

How?

The bulk of the logic lives in a new src/api/pdfa/ module (conformance parsing, XMP builder, bundled sRGB profile), and PDFDocument.convertToPDFA() orchestrates it using existing primitives (context.stream, context.register, catalog.set, getTitle()/getAuthor()/… for XMP consistency). No parsing/serialization internals were changed for the feature itself.

One supporting change was required in the writers. PDFWriter and PDFStreamWriter hardcoded the output header to PDFHeader.forVersion(1, 7), ignoring context.header — even though PDFParser sets context.header from the source file. I changed both to honor context.header. The default is still 1.7 (that's what PDFContext initializes), so newly-created documents are unaffected; the observable change is that loaded documents now preserve their original header version on save instead of being silently promoted to 1.7, and PDF/A-1 can emit a 1.4 header. See "Anything Else?" for the compatibility note.

Alternatives considered:

  • Injecting the header only at the PDF/A call site — rejected because the writer would still overwrite it; honoring context.header is the correct, minimal fix and makes the existing (parser-populated) field actually meaningful on the write path.
  • Level "A" (tagged) conformance — intentionally not supported, because it requires a fully tagged/structured document that cannot be produced automatically. convertToPDFA throws a descriptive error for A-levels.

Testing?

  • Unit tests — added tests/api/pdfa.spec.ts (16 tests) covering conformance parsing, the XMP builder (including XML escaping and field mirroring), the bundled ICC profile, the output intent / metadata / /ID / header-version structures, the "no object streams for part 1" rule, custom-profile options, and the encrypted-document and unsupported-conformance error paths. Full suite: 710 passing.
  • External validator — generated documents for 1B, 2B, 3B, and 3U and validated each with veraPDF 1.30.2: all report isCompliant="true" with 0 failed checks.
  • ZUGFeRD/Factur-X — built a PDF/A-3B invoice embedding a Cross-Industry-Invoice factur-x.xml (via existing attach() with AFRelationship.Alternative) plus the Factur-X XMP extension schema, and validated it with the Mustang / EU-Rechnung online validator: PDF part conformant, recognized as ZUGFeRD 2 (BASIC).
  • yarn typecheck, yarn lint (0 errors), and yarn build (cjs/es/esm/umd + downlevel-dts) all pass. The Node integration app (apps/node) compiles against the built library.

Note for reviewers: I have not run all the manual multi-environment integration tests. It seemed a bit tideous. I ran it for node which mattered most to me.

New Dependencies?

No.

(The default sRGB ICC profile is bundled as an inlined base64 constant in src/api/pdfa/srgbProfile.ts — no runtime dependency is added. It is the freely-redistributable sRGB IEC61966-2.1 profile, © 1998 Hewlett-Packard, the same profile bundled by Ghostscript, Apache PDFBox, and Little CMS. It adds ~3 KB (binary) / ~4 KB (base64 source) to the bundle.

Screenshots

N/A — the feature adds archival/metadata structures and does not change how existing content is rendered.

Suggested Reading?

Yes — in addition to the PDF spec sections listed in CONTRIBUTING.md, the relevant references for this change are ISO 19005 (PDF/A parts 1–3), the XMP specification (for the metadata packet and PDF/A extension-schema mechanism), and the ICC / OutputIntent handling in ISO 32000.

Anything Else?

  1. Behavior change in the writers. Making PDFWriter/PDFStreamWriter honor context.header changes the emitted header for loaded documents from a forced 1.7 to their original version. All unit tests pass, as I said the node integration test also worked fine, I dont think it matters, but you should know.

  2. Content compliance is the caller's responsibility. convertToPDFA() performs the structural changes but cannot make arbitrary content compliant — most importantly, drawn text must use an embedded font (the 14 standard fonts are not PDF/A compliant), and PDF/A also forbids encryption, transparency (part 1), JavaScript, etc. This is documented in the method's doc comment, which points users to veraPDF for validation.

Checklist

  • I read CONTRIBUTING.md.
  • I read MAINTAINERSHIP.md#pull-requests.
  • I added/updated unit tests for my changes.
  • I added/updated integration tests for my changes.
  • [ x ] I ran the integration tests.
  • I tested my changes in Node, Deno, and the browser.
  • I viewed documents produced with my changes in Adobe Acrobat, Foxit Reader, Firefox, and Chrome. (Validated with veraPDF and preview)
  • I added/updated doc comments for any new/modified public APIs.
  • My changes work for both new and existing PDF files.
  • I ran the linter on my changes.

@Sharcoux

Sharcoux commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

I let Claude Opus write this and only reviewed it. I ran some of the integration tests, which looked as goofy as before. I did not run e.g. RN integration tests.

Well, I let an AI do the review. This way we're even ;)

Thanks for this — solid foundation for PDF/A (ID, OutputIntent + sRGB, uncompressed XMP, header/version handling for part 1). The writer change to honor context.header looks correct and necessary.

A few points before merge:

Blocking

  1. XMP stream encoding is not UTF-8

convertToPDFA passes the metadata string into context.stream(), which uses typedArrayFor (charCodeAt → low byte). That:

corrupts begin="\uFEFF" (becomes 0xFF instead of UTF-8 EF BB BF)
mangles non-ASCII metadata (umlauts, accents, etc.) → Info dict and XMP diverge → PDF/A failure
This matters a lot for ZUGFeRD/Factur-X (DE/FR/AT). Please encode with utf8Encode(metadataXML, false) (or equivalent) before creating the stream, and add a test with a unicode title like Müller & Cie .

  1. closes #27 looks wrong

#27 is about loading broken/encrypted PDFs, not generating PDF/A. Upstream #1183 (or a dedicated “add PDF/A” issue) would be the right reference.

  1. Info ↔ XMP can drift after convertToPDFA

XMP is built once at call time. Later setTitle / setAuthor / setModificationDate / etc. update the Info dict but not /Metadata, which violates PDF/A consistency. Prefer regenerating XMP in prepareForSave when pdfAConformance is set (or document clearly + provide a refresh helper).

Non-blocking / follow-ups
ZUGFeRD/Factur-X: this lands the PDF/A-3 scaffolding, not the fx: XMP + extension schema. Worth saying explicitly in the PR/commit so expectations stay clear.
2U / 3U: accepted without checking ToUnicode on existing content — fine if documented as “caller responsibility”, but easy to misunderstand.
/ID via Math.random: works for validators; aligning with the existing CryptoJS/PDFSecurity ID helper would be nicer.
Optional: assertIs on options for consistency with the rest of the API; guard if encrypt() is called after conversion.
Happy to re-review once the UTF-8 encoding + issue reference are fixed.

@FrauElster
FrauElster force-pushed the master branch 2 times, most recently from 68e120c to 8a0ceff Compare July 29, 2026 12:59
@FrauElster

Copy link
Copy Markdown
Author

Great times we live in ;)

I told my AI what your AI found, let it churn a bit, validated the output again and let it add a Test for the unicode issue.

Now I ping it back to you and your AI.

@Sharcoux
Sharcoux merged commit 3d1d187 into cantoo-scribe:master Jul 30, 2026
1 check passed
@Sharcoux

Copy link
Copy Markdown
Collaborator

Thanks for the latest change. I noticed some minor problems remaining while reviewing but I'll handle that manually before the release. This PR will be included in the upcoming release today.

@Sharcoux

Copy link
Copy Markdown
Collaborator

Version 2.8.0 released

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants