Files
TimeTracker/tests/test_pdfa3.py
T
Dries Peeters 70510a6622 fix: quote create 500, line order, and Factur-X PDF parity
Quotes (#583):
- Add requires_approval, approval_level, and can_be_sent; wire create form
- Migrations 145 (approval columns) and 146 (quote_items.position)
- Order Quote.items by position; set positions on create/edit/duplicate/API
- Fix view template approval branch (not_required); add web regression test

Invoices / PEPPOL:
- Use the same Factur-X embed and PDF/A-3 normalization for export and
  email attachments; Associated File Data + text/xml metadata
- CII/UBL validators, pdfa3, zugferd, and invoice_pdf_postprocess helper
- Bundle compact sRGB ICC (app/resources/icc/); INVOICE_SRGB_ICC_PATH override
- Package data in setup.py; extend PEPPOL_EINVOICING.md and tests
2026-04-12 13:34:58 +02:00

175 lines
5.6 KiB
Python

"""Tests for PDF/A-3 conversion with ICC profile embedding."""
import io
import os
import pytest
try:
import pikepdf
except ImportError:
pikepdf = None
@pytest.mark.unit
@pytest.mark.skipif(not pikepdf, reason="pikepdf not installed")
def test_convert_to_pdfa3_adds_identification(app):
"""PDF/A-3 conversion adds XMP pdfaid identification."""
from app.utils.pdfa3 import convert_to_pdfa3
pdf = pikepdf.Pdf.new()
pdf.add_blank_page(page_size=(595, 842))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
pdf_bytes = buf.getvalue()
out_bytes, err = convert_to_pdfa3(pdf_bytes)
assert err is None
assert len(out_bytes) >= len(pdf_bytes)
result = pikepdf.open(io.BytesIO(out_bytes))
assert result.Root.get("/Metadata") is not None
xmp = result.Root.Metadata.read_bytes().decode("utf-8", errors="replace")
assert "pdfaid:part" in xmp
assert ">3<" in xmp or "3</pdfaid:part>" in xmp
assert "pdfaid:conformance" in xmp
result.close()
@pytest.mark.unit
@pytest.mark.skipif(not pikepdf, reason="pikepdf not installed")
def test_convert_to_pdfa3_adds_output_intent(app):
"""PDF/A-3 conversion adds an OutputIntent with ICC profile reference."""
from app.utils.pdfa3 import convert_to_pdfa3
pdf = pikepdf.Pdf.new()
pdf.add_blank_page(page_size=(595, 842))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
pdf_bytes = buf.getvalue()
out_bytes, err = convert_to_pdfa3(pdf_bytes)
assert err is None
result = pikepdf.open(io.BytesIO(out_bytes))
intents = result.Root.get("/OutputIntents")
assert intents is not None
assert len(intents) > 0
intent = intents[0]
assert str(intent.get("/S")) == "/GTS_PDFA1"
# Check that DestOutputProfile (ICC stream) is present
dest_profile = intent.get("/DestOutputProfile")
assert dest_profile is not None, "OutputIntent should contain an embedded ICC profile"
result.close()
@pytest.mark.unit
@pytest.mark.skipif(not pikepdf, reason="pikepdf not installed")
def test_convert_to_pdfa3_icc_profile_is_valid(app):
"""The embedded ICC profile has the correct signature and structure."""
from app.utils.pdfa3 import convert_to_pdfa3
import struct
pdf = pikepdf.Pdf.new()
pdf.add_blank_page(page_size=(595, 842))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
pdf_bytes = buf.getvalue()
out_bytes, err = convert_to_pdfa3(pdf_bytes)
assert err is None
result = pikepdf.open(io.BytesIO(out_bytes))
intent = result.Root.OutputIntents[0]
icc_stream = intent.DestOutputProfile
icc_data = icc_stream.read_bytes()
result.close()
# ICC profile must be at least 128 bytes (header size)
assert len(icc_data) >= 128
# Profile size in header must match actual size
profile_size = struct.unpack(">I", icc_data[:4])[0]
assert profile_size == len(icc_data)
# Signature must be 'acsp' at offset 36
assert icc_data[36:40] == b"acsp"
# Color space must be 'RGB '
assert icc_data[16:20] == b"RGB "
@pytest.mark.unit
@pytest.mark.skipif(not pikepdf, reason="pikepdf not installed")
def test_convert_to_pdfa3_preserves_existing_xmp(app):
"""Conversion preserves existing XMP metadata while adding PDF/A-3 identification."""
from app.utils.pdfa3 import convert_to_pdfa3
pdf = pikepdf.Pdf.new()
pdf.add_blank_page(page_size=(595, 842))
existing_xmp = (
'<?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?>'
'<x:xmpmeta xmlns:x="adobe:ns:meta/">'
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
'<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">'
"<dc:title>Test Invoice</dc:title>"
"</rdf:Description>"
"</rdf:RDF></x:xmpmeta>"
'<?xpacket end="w"?>'
)
pdf.Root.Metadata = pdf.make_stream(existing_xmp.encode("utf-8"))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
out_bytes, err = convert_to_pdfa3(buf.getvalue())
assert err is None
result = pikepdf.open(io.BytesIO(out_bytes))
xmp = result.Root.Metadata.read_bytes().decode("utf-8", errors="replace")
result.close()
assert "pdfaid:part" in xmp
assert "dc:title" in xmp, "Existing XMP metadata should be preserved"
@pytest.mark.unit
def test_convert_to_pdfa3_returns_error_on_invalid_pdf(app):
from app.utils.pdfa3 import convert_to_pdfa3
out_bytes, err = convert_to_pdfa3(b"not a pdf")
assert err is not None
assert out_bytes == b"not a pdf"
@pytest.mark.unit
def test_bundled_srgb_icc_file_present():
"""Shipped nano sRGB profile is on disk for PDF/A DestOutputProfile."""
from app.utils.pdfa3 import _BUNDLED_SRGB_ICC
assert _BUNDLED_SRGB_ICC.is_file(), f"Missing bundled ICC: {_BUNDLED_SRGB_ICC}"
@pytest.mark.unit
@pytest.mark.skipif(not pikepdf, reason="pikepdf not installed")
def test_verapdf_when_invoice_verapdf_path_configured(app):
"""Optional: full minimal PDF → PDF/A-3 pipeline checked with veraPDF if path is set."""
from app.utils.invoice_validators import validate_pdfa_verapdf
from app.utils.pdfa3 import convert_to_pdfa3
verapdf_path = (os.environ.get("INVOICE_VERAPDF_PATH") or "").strip()
if not verapdf_path or not os.path.isfile(verapdf_path):
pytest.skip("INVOICE_VERAPDF_PATH not set (optional CI/local check)")
pdf = pikepdf.Pdf.new()
pdf.add_blank_page(page_size=(595, 842))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
out, err = convert_to_pdfa3(buf.getvalue())
assert err is None
passed, msgs = validate_pdfa_verapdf(out, verapdf_path=verapdf_path)
assert passed is True, f"veraPDF reported: {msgs}"