DEV Community

Lumaspdf
Lumaspdf

Posted on

A signed PDF/A-1b file from Python in 40 lines, no compiled extension

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Run it:

python signed_pdfa.py sample_rgb.icc test_cert.pfx 123456
written: C:\work\signed_pdfa.pdf 105599 bytes
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
to21as profile image
Tobias •

The signature goes on in pdfCloseAndSignFile, after pdfCheckConformance has 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.

Collapse
 
lumaspdf profile image
Lumaspdf •

@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:

  • 6.9-2, field without an appearance dictionary: my fault in the trimmed script. The shipped example calls pdfCreateSigFieldAP; I had dropped that line, and the engine does not synthesise a signature appearance at signing time as I implied. Fixed in the listing.
  • 6.7.3-2, Info /Title with no matching XMP dc:title: engine defect. The PDF/A path only wrote dc:title when PDF/UA was also on. Fixed in both engine builds.
  • 6.3.5-3, CIDFontType2 subset without a /CIDSet stream: engine defect, and it affected the full example in the download too. Fixed in both engine builds.

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.