mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 13:20:38 -05:00
70510a6622
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
175 lines
5.6 KiB
Python
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}"
|