Most PDF libraries for Python can write a PDF. Far fewer can produce a file that a validator accepts as PDF/A-1b, and fewer still can put a digital signature on it without shelling out to a second tool. Here is the whole job in one script, using the LumasPDF SDK through its Python wheel. The binding is ctypes over a native engine, so there is nothing to compile.
Disclosure: I'm the author of LumasPDF.
Install
pip install lumaspdf
Wheels exist for Windows x64/x86, Linux x64 and macOS. The engine for your platform is inside the wheel. The download is free; unlicensed output carries an evaluation watermark, and a trial key removes it.
You also need two files: an sRGB ICC profile and a PKCS#12 certificate. Both ship as test fixtures in the SDK download (test_files/sample_rgb.icc and test_files/test_cert.pfx, password 123456), or use your own.
The script
"""Create a PDF/A-1b file and sign it, from Python, with the lumaspdf wheel.
pip install lumaspdf
python signed_pdfa.py sRGB.icc cert.pfx cert-password
"""
import os, sys, ctypes
import lumaspdf as L
icc, pfx, pwd = sys.argv[1], sys.argv[2], sys.argv[3]
out = os.path.abspath("signed_pdfa.pdf")
# Errors arrive through a callback; return 0 to let CheckConformance apply fixes.
ERR = ctypes.WINFUNCTYPE if os.name == "nt" else ctypes.CFUNCTYPE
@ERR(ctypes.c_int32, ctypes.c_void_p, ctypes.c_int32, ctypes.c_char_p, ctypes.c_int32)
def on_error(data, code, msg, kind):
print("engine:", msg.decode("latin-1", "replace"))
return 0
pdf = L.pdfNewPDF()
L.pdfSetOnErrorProc(pdf, 0, on_error)
L.pdfCreateNewPDFA(pdf, b"") # output file is chosen at the end
L.pdfSetDocInfoA(pdf, L.diTitle, b"Signed PDF/A-1b from Python")
L.pdfAppend(pdf)
# PDF/A needs every font embedded, so use a real TrueType face, not a base-14 name.
L.pdfSetFontA(pdf, b"Arial", L.fsNone, 11.0, 1, L.cp1252)
L.pdfWriteFTextA(pdf, L.taLeft, b"This file is PDF/A-1b and carries a digital signature.")
# A visible signature field. PDF/A requires every field to carry an appearance
# stream, so build the default one (certificate details go into it at signing).
field = L.pdfCreateSigField(pdf, b"Signature", -1, 350.0, 700.0, 200.0, 60.0)
L.pdfCreateSigFieldAP(pdf, field)
L.pdfEndTemplate(pdf)
L.pdfEndPage(pdf)
# Check conformance. The return value says which colour space needs an output intent.
rc = L.pdfCheckConformance(pdf, L.ctPDFA_1b_2005, 0, None,
L.TOnFontNotFoundProc(0), L.TOnReplaceICCProfile(0))
if rc in (1, 3): # gray or RGB content
L.pdfAddOutputIntentA(pdf, icc.encode("latin-1"))
elif rc == 2: # CMYK content: use a CMYK profile instead
L.pdfAddOutputIntentA(pdf, icc.encode("latin-1"))
if L.pdfHaveOpenDoc(pdf) and L.pdfOpenOutputFileA(pdf, out.encode("latin-1")):
# Signs with the PKCS#12 certificate and writes the file in one step.
if L.pdfCloseAndSignFile(pdf, pfx.encode("latin-1"), pwd.encode(), b"Approved", b"") != 0:
print("written:", out, os.path.getsize(out), "bytes")
L.pdfDeletePDF(pdf)
Run it:
python signed_pdfa.py sample_rgb.icc test_cert.pfx 123456
written: C:\work\signed_pdfa.pdf 105599 bytes
What each step is doing
The error callback. The engine reports problems through a callback rather than exceptions. Returning 0 means "carry on"; that matters for pdfCheckConformance, which repairs a document (sets missing flags, drops forbidden features) and tells you what it changed through this callback. Return -1 instead if you want any change to abort the run.
pdfCreateNewPDFA(pdf, b""). The empty name defers the output file. Signing needs to write the whole file in one pass at the end, so the output is opened only after the conformance check has finished.
Embedded fonts. PDF/A forbids unembedded fonts, including the fourteen standard ones. pdfSetFontA(..., 1, ...) embeds a real TrueType face. On Linux and macOS, point the engine at a font directory first with pdfAddFontSearchPathA, since there is no system font registry to fall back on.
The signature field. pdfCreateSigField places a visible field and pdfCreateSigFieldAP opens its default appearance template, which the signing step fills with the certificate's subject and date; pdfEndTemplate closes it. Skip the appearance and PDF/A-1b rule 6.9 fails, because every field must carry an appearance dictionary. PDF/A also requires signature fields to be visible and printable, and the conformance check enforces that.
pdfCheckConformance. The return value tells you which colour spaces the document uses: 1 gray, 2 CMYK, 3 RGB. PDF/A-1b needs an output intent matching that, hence the ICC profile. The TOnFontNotFoundProc(0) and TOnReplaceICCProfile(0) arguments are null callbacks; supply real ones if you want to substitute fonts or profiles interactively.
pdfCloseAndSignFile. Takes the PKCS#12 file, its password, a reason and a location, and produces a PAdES-style signature with a ByteRange covering the whole file. The result stays PDF/A-1b; signing after the fact with a different tool is where conformance usually breaks.
Check it with an independent validator
Do not take the producer's word for it, including mine. Put the signed file through veraPDF:
verapdf --flavour 1b signed_pdfa.pdf
Update, 10 September 2026. A reader asked whether I had done exactly that. I had not, and the first version of this script did not pass. veraPDF (1b profile) reported three failed rules on its output:
| Rule | What it means | Cause |
|---|---|---|
| 6.9-2 | a form field without an appearance dictionary | the script omitted pdfCreateSigFieldAP; fixed above |
| 6.7.3-2 | Info /Title present but no matching XMP dc:title
|
engine: the PDF/A path only wrote dc:title when PDF/UA was also requested |
| 6.3.5-3 | CIDFontType2 subset without a /CIDSet stream |
engine: no CIDSet was written for embedded CID subsets |
The script fix is in the listing above. The two engine findings are fixed in LumasPDF 4.2.3 (pip install --upgrade lumaspdf). On that build, veraPDF's verdict for the exact output of this script is:
article-script / 4.2.3 (win-x64) compliant: True failed rules: 0
shipped signed_pdfa example / 4.2.3 compliant: True failed rules: 0
The signature itself, written last as an incremental update, raised no rule on any build. Full notes for the release: https://www.lumaspdf.com/releases/4.2.3.
Adobe Acrobat's signature panel shows the field as signed; it will flag the test certificate as untrusted, which is expected for a self-signed fixture.
Where this goes next
The same calls exist in every LumasPDF binding, under the same names: C, C++, .NET, Delphi, PHP, Node, Flutter. The function reference is at https://www.lumaspdf.com/reference, and the full example set, including this one in each language, is at https://github.com/Lumaspdf/lumaspdf-examples.
Top comments (2)
The signature goes on in
pdfCloseAndSignFile, afterpdfCheckConformancehas already run and repaired the document, so the conformance verdict is about the pre-signature bytes. Have you put the signed output through veraPDF? I would assume the incremental update is fine under PDF/A-1b, but the only number I trust on our side is an independent validator on the final file rather than the producer's own verdict on an earlier one.@to21as
Fair challenge, and you were right to make it. I ran the exact output of the script through veraPDF (1b profile) and it was not compliant. Three rules failed:
With the fixed engines the signed file passes 1b with zero failed rules, for this script and for the shipped example. The signature step itself, written last as an incremental update, raised no rule. The fix ships with the next build; until then the published wheel still fails the two engine rules, and the article now says so in an update table instead of "you should see isCompliant=true". Thanks for holding it to the right standard.