feat(invoicing): add ZugFerd/Factur-X support and document Peppol & ZugFerd

- Add optional embedding of EN 16931 UBL XML in invoice PDFs (ZugFerd/Factur-X)
  when 'Embed EN 16931 XML in invoice PDFs' is enabled in Admin > Peppol e-Invoicing.
  Exported PDFs then contain ZUGFeRD-invoice.xml for hybrid human- and machine-readable
  invoices; same UBL as Peppol, usable via Peppol or email.
- New setting invoices_zugferd_pdf (migration 128), pikepdf dependency, and
  app.utils.zugferd helper (best-effort supplier/customer from Settings and client).
- Wire embed in export_invoice_pdf (and fallback path); admin checkbox and persistence.
- Docs: PEPPOL_EINVOICING.md retitled to 'Peppol and ZugFerd', new section for
  ZugFerd embedding; README and CHANGELOG updated; migration 128 noted.
- Tests: test_zugferd.py (embed adds attachment with expected XML; invalid PDF
  returns original bytes and error).
This commit is contained in:
Dries Peeters
2026-02-16 07:36:49 +01:00
parent ae9ee9dec1
commit b0809e2f90
11 changed files with 369 additions and 6 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **ZugFerd / Factur-X support for invoice PDFs** — When enabled in Admin → Settings → Peppol e-Invoicing, exported invoice PDFs embed EN 16931 UBL XML as `ZUGFeRD-invoice.xml`, producing hybrid human- and machine-readable invoices. Uses the same UBL as Peppol; these PDFs can be sent via Peppol or email. New setting `invoices_zugferd_pdf`, migration `128_add_invoices_zugferd_pdf`, dependency `pikepdf`, and [docs/admin/configuration/PEPPOL_EINVOICING.md](docs/admin/configuration/PEPPOL_EINVOICING.md) updated for both Peppol and ZugFerd.
- **Subcontractor role and assigned clients** — Users with the Subcontractor role can be restricted to specific clients and their projects. Admins assign clients in Admin → Users → Edit user (section "Assigned Clients (Subcontractor)"). Scope is applied to clients, projects, time entries, reports, invoices, timer, and API v1; direct access to other clients/projects returns 403. New table `user_clients`, migration `127_add_user_clients_table`, and docs in [docs/SUBCONTRACTOR_ROLE.md](docs/SUBCONTRACTOR_ROLE.md).
- Additional features and improvements in development
+3 -3
View File
@@ -105,7 +105,7 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh
- **Invoice Status Tracking** — Monitor draft, sent, paid, and overdue invoices
- **Recurring Invoices** — Automate regular billing cycles
- **Email Integration** — Send invoices directly to clients from the platform
- **Peppol e-Invoicing (BIS Billing 3.0)** — Send invoices via Peppol through your access point ([setup guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
- **Peppol & ZugFerd e-Invoicing (EN 16931)** — Send invoices via Peppol; optionally embed EN 16931 XML in invoice PDFs (ZugFerd/Factur-X) for hybrid human- and machine-readable invoices ([setup guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
#### 📋 **Advanced Task Management**
- **Full Task System** — Create, assign, and track tasks with priorities and due dates
@@ -235,7 +235,7 @@ TimeTracker includes **130+ features** across 13 major categories. See the [Comp
- **Recurring Invoices** — Automate recurring billing
- **Multi-Currency** — Support for multiple currencies with conversion
- **Invoice Email** — Send invoices directly to clients
- **Peppol e-Invoicing (BIS Billing 3.0)** — Send invoices electronically via Peppol ([Setup Guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
- **Peppol & ZugFerd e-Invoicing (EN 16931)** — Send invoices via Peppol; optionally embed EN 16931 XML in PDFs (ZugFerd/Factur-X) ([Setup Guide](docs/admin/configuration/PEPPOL_EINVOICING.md))
### 💰 **Financial Management**
- **Expense Tracking** — Track business expenses with receipts and categories ([Guide](docs/EXPENSE_TRACKING.md))
@@ -624,7 +624,7 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory. See
**Integrations & Apps:**
- **[Mobile & Desktop Apps](docs/mobile-desktop-apps/README.md)** — Flutter mobile and Electron desktop apps
- **[Build Guide (Mobile & Desktop)](BUILD.md)** — Build scripts for Android, iOS, Windows, macOS, Linux
- **[Peppol e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md)** — Electronic invoicing
- **[Peppol & ZugFerd e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md)** — Peppol sending and ZugFerd/Factur-X PDF embedding (EN 16931)
- **[API Documentation](docs/api/REST_API.md)** — REST API reference
- **[API Token Scopes](docs/api/API_TOKEN_SCOPES.md)** — Token permissions
+4
View File
@@ -75,6 +75,8 @@ class Settings(db.Model):
peppol_access_point_timeout = db.Column(db.Integer, default=30, nullable=True)
peppol_provider = db.Column(db.String(50), default="generic", nullable=True)
invoices_peppol_compliant = db.Column(db.Boolean, default=False, nullable=False)
# When True, exported invoice PDFs embed EN 16931 UBL XML (ZugFerd/Factur-X)
invoices_zugferd_pdf = db.Column(db.Boolean, default=False, nullable=False)
# Privacy and analytics settings
allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics
@@ -208,6 +210,7 @@ class Settings(db.Model):
self.peppol_access_point_timeout = kwargs.get("peppol_access_point_timeout", 30)
self.peppol_provider = kwargs.get("peppol_provider", "generic")
self.invoices_peppol_compliant = kwargs.get("invoices_peppol_compliant", False)
self.invoices_zugferd_pdf = kwargs.get("invoices_zugferd_pdf", False)
# Kiosk mode defaults
self.kiosk_mode_enabled = kwargs.get("kiosk_mode_enabled", False)
@@ -420,6 +423,7 @@ class Settings(db.Model):
"peppol_access_point_timeout": getattr(self, "peppol_access_point_timeout", None),
"peppol_provider": getattr(self, "peppol_provider", "") or "",
"invoices_peppol_compliant": getattr(self, "invoices_peppol_compliant", False),
"invoices_zugferd_pdf": getattr(self, "invoices_zugferd_pdf", False),
"invoice_pdf_template_html": self.invoice_pdf_template_html,
"invoice_pdf_template_css": self.invoice_pdf_template_css,
"invoice_pdf_design_json": self.invoice_pdf_design_json,
+1
View File
@@ -1246,6 +1246,7 @@ def settings():
settings_obj.peppol_provider = (request.form.get("peppol_provider", "") or "").strip() or "generic"
settings_obj.invoices_peppol_compliant = request.form.get("invoices_peppol_compliant") == "on"
settings_obj.invoices_zugferd_pdf = request.form.get("invoices_zugferd_pdf") == "on"
except AttributeError:
# Peppol columns don't exist yet (migration not run)
pass
+11
View File
@@ -1149,6 +1149,12 @@ def export_invoice_pdf(invoice_id):
pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size=page_size)
current_app.logger.info(f"[PDF_EXPORT] Starting PDF generation - PageSize: '{page_size}', InvoiceID: {invoice_id}")
pdf_bytes = pdf_generator.generate_pdf()
# Optionally embed ZugFerd/Factur-X EN 16931 XML in PDF
if getattr(settings, "invoices_zugferd_pdf", False):
from app.utils.zugferd import embed_zugferd_xml_in_pdf
pdf_bytes, embed_err = embed_zugferd_xml_in_pdf(pdf_bytes, invoice, settings)
if embed_err:
current_app.logger.warning(f"[PDF_EXPORT] ZugFerd embed skipped - InvoiceID: {invoice_id}, Error: {embed_err}")
pdf_size_bytes = len(pdf_bytes)
current_app.logger.info(f"[PDF_EXPORT] PDF generation completed successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
# Filename should be template+date+number (invoice number format)
@@ -1166,6 +1172,11 @@ def export_invoice_pdf(invoice_id):
settings = Settings.get_settings()
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
if getattr(settings, "invoices_zugferd_pdf", False):
from app.utils.zugferd import embed_zugferd_xml_in_pdf
pdf_bytes, embed_err = embed_zugferd_xml_in_pdf(pdf_bytes, invoice, settings)
if embed_err:
current_app.logger.warning(f"[PDF_EXPORT] ZugFerd embed skipped (fallback path) - InvoiceID: {invoice_id}, Error: {embed_err}")
pdf_size_bytes = len(pdf_bytes)
current_app.logger.info(f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
# Filename should be template+date+number (invoice number format)
+10
View File
@@ -267,6 +267,16 @@
</div>
</div>
<div class="md:col-span-2 flex items-start">
<input type="checkbox" name="invoices_zugferd_pdf" id="invoices_zugferd_pdf" {% if settings.invoices_zugferd_pdf %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 mt-1">
<div class="ml-2">
<label for="invoices_zugferd_pdf" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Embed EN 16931 XML in invoice PDFs (ZugFerd / Factur-X)</label>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
When enabled, exported invoice PDFs contain an embedded XML file (ZUGFeRD-invoice.xml) so the file is both human-readable and machine-readable (EN 16931). These PDFs can also be sent via Peppol.
</p>
</div>
</div>
<div>
<label for="peppol_sender_endpoint_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Sender Endpoint ID</label>
<input type="text" name="peppol_sender_endpoint_id" id="peppol_sender_endpoint_id" value="{{ settings.peppol_sender_endpoint_id or '' }}" class="form-input" placeholder="e.g. 9915:BE0123456789">
+114
View File
@@ -0,0 +1,114 @@
"""
ZugFerd / Factur-X: embed EN 16931 UBL XML into invoice PDFs.
When enabled, exported invoice PDFs contain an embedded XML file (ZUGFeRD-invoice.xml)
so the file is both human-readable (PDF) and machine-readable (EN 16931). The same
UBL used for Peppol is reused; embedding is done with pikepdf.
"""
from __future__ import annotations
import io
import os
from typing import Any, Optional, Tuple
from app.integrations.peppol import PeppolParty, build_peppol_ubl_invoice_xml
# Standard embedded filename for ZUGFeRD/Factur-X (EN 16931)
ZUGFERD_EMBEDDED_FILENAME = "ZUGFeRD-invoice.xml"
def _get_sender_party_for_zugferd(settings: Any) -> PeppolParty:
"""Build supplier party from Settings (best-effort; placeholders if missing)."""
sender_endpoint_id = (
(getattr(settings, "peppol_sender_endpoint_id", "") or os.getenv("PEPPOL_SENDER_ENDPOINT_ID") or "").strip()
or "unknown"
)
sender_scheme_id = (
(getattr(settings, "peppol_sender_scheme_id", "") or os.getenv("PEPPOL_SENDER_SCHEME_ID") or "").strip()
or "0000"
)
sender_country = (
(getattr(settings, "peppol_sender_country", "") or os.getenv("PEPPOL_SENDER_COUNTRY") or "").strip()
or None
)
return PeppolParty(
endpoint_id=sender_endpoint_id,
endpoint_scheme_id=sender_scheme_id,
name=(getattr(settings, "company_name", None) or "Company").strip(),
tax_id=(getattr(settings, "company_tax_id", None) or "").strip() or None,
address_line=(getattr(settings, "company_address", None) or "").strip() or None,
country_code=sender_country,
email=(getattr(settings, "company_email", None) or "").strip() or None,
phone=(getattr(settings, "company_phone", None) or "").strip() or None,
)
def _get_customer_party_for_zugferd(invoice: Any) -> PeppolParty:
"""Build customer party from invoice and client (best-effort; placeholders if missing)."""
client = getattr(invoice, "client", None)
endpoint_id = "unknown"
scheme_id = "0000"
country = None
name = (getattr(invoice, "client_name", None) or "Customer").strip()
tax_id = None
address_line = None
email = None
phone = None
if client:
endpoint_id = (client.get_custom_field("peppol_endpoint_id", "") or "").strip() or "unknown"
scheme_id = (client.get_custom_field("peppol_scheme_id", "") or "").strip() or "0000"
country = (client.get_custom_field("peppol_country", "") or "").strip() or None
name = (getattr(client, "name", None) or getattr(invoice, "client_name", "") or "Customer").strip()
tax_id = (client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "") or "").strip() or None
address_line = (getattr(client, "address", None) or getattr(invoice, "client_address", None) or "").strip() or None
email = (getattr(client, "email", None) or getattr(invoice, "client_email", None) or "").strip() or None
phone = (getattr(client, "phone", None) or "").strip() or None
return PeppolParty(
endpoint_id=endpoint_id,
endpoint_scheme_id=scheme_id,
name=name,
tax_id=tax_id,
address_line=address_line,
country_code=country,
email=email,
phone=phone,
)
def embed_zugferd_xml_in_pdf(pdf_bytes: bytes, invoice: Any, settings: Any) -> Tuple[bytes, Optional[str]]:
"""
Embed EN 16931 UBL XML into the given invoice PDF bytes (ZugFerd/Factur-X).
Builds supplier/customer from settings and invoice (best-effort), generates UBL,
attaches it as ZUGFeRD-invoice.xml, and returns the new PDF bytes.
Returns:
(new_pdf_bytes, None) on success, or (original_pdf_bytes, error_message) on failure.
"""
try:
import pikepdf
from pikepdf import AttachedFileSpec
except ImportError as e:
return pdf_bytes, f"pikepdf not available: {e}"
try:
supplier = _get_sender_party_for_zugferd(settings)
customer = _get_customer_party_for_zugferd(invoice)
ubl_xml, _ = build_peppol_ubl_invoice_xml(invoice=invoice, supplier=supplier, customer=customer)
except Exception as e:
return pdf_bytes, f"Failed to build UBL for ZugFerd: {e}"
try:
pdf = pikepdf.open(io.BytesIO(pdf_bytes))
filespec = AttachedFileSpec(pdf, ubl_xml.encode("utf-8"), mime_type="application/xml")
pdf.attachments[ZUGFERD_EMBEDDED_FILENAME] = filespec
out = io.BytesIO()
pdf.save(out, min_version="1.7")
pdf.close()
return out.getvalue(), None
except Exception as e:
return pdf_bytes, f"Failed to embed ZugFerd XML in PDF: {e}"
+19 -3
View File
@@ -1,6 +1,11 @@
# Peppol e-invoicing (BIS Billing 3.0)
# Peppol and ZugFerd e-invoicing (EN 16931)
TimeTracker can **send invoices via Peppol** by generating a UBL 2.1 Invoice (Peppol BIS Billing 3.0 profile) and forwarding it to your **Peppol Access Point**.
TimeTracker supports **both**:
- **Peppol** send invoices via the Peppol network (UBL 2.1, BIS Billing 3.0) to your **Peppol Access Point**.
- **ZugFerd / Factur-X** export invoice PDFs that contain **embedded EN 16931 XML** (one file that is both human-readable and machine-readable). These hybrid PDFs can also be sent via Peppol.
Peppol is the **transport**; ZugFerd is a **format** (PDF + embedded XML). The same UBL used for Peppol is reused when embedding (EN 16931 compliant).
## What you need
@@ -85,6 +90,16 @@ You can optionally set **Buyer reference (PEPPOL BT-10)** on each invoice (creat
When the setting is on **and** the client has Peppol endpoint details, the invoice view shows a **Download UBL** button to save the UBL 2.1 XML file.
## Embed EN 16931 XML in invoice PDFs (ZugFerd / Factur-X)
In **Admin → Settings → Peppol e-Invoicing** you can enable **Embed EN 16931 XML in invoice PDFs (ZugFerd / Factur-X)**. When this is on:
- **Exported invoice PDFs** (Export PDF) contain an embedded file `ZUGFeRD-invoice.xml` with the same EN 16931 UBL as used for Peppol.
- The PDF remains human-readable; the embedded XML makes it machine-readable (e.g. for automated booking or archiving).
- These hybrid PDFs can be sent via Peppol or by email; recipients can open the PDF and/or extract the XML.
Party data (seller/customer) is taken from Settings and the invoices client (including Peppol endpoint fields). For full EN 16931/ZugFerd compliance, configure sender and client data as for Peppol. If some data is missing, the app still embeds best-effort UBL so the file is usable.
## Migrations
After pulling these changes, run:
@@ -93,10 +108,11 @@ After pulling these changes, run:
flask db upgrade
```
This applies:
This applies (among others):
- `112_add_invoices_peppol_compliant` (adds `settings.invoices_peppol_compliant`)
- `113_add_invoice_buyer_reference` (adds `invoices.buyer_reference`)
- `128_add_invoices_zugferd_pdf` (adds `settings.invoices_zugferd_pdf` for ZugFerd PDF embedding)
## Testing
@@ -0,0 +1,74 @@
"""Add invoices_zugferd_pdf to settings
Revision ID: 128_add_invoices_zugferd_pdf
Revises: 127_add_user_clients
Create Date: 2026-02-16
When enabled, exported invoice PDFs embed EN 16931 UBL XML (ZugFerd/Factur-X).
"""
from alembic import op
import sqlalchemy as sa
revision = "128_add_invoices_zugferd_pdf"
down_revision = "127_add_user_clients"
branch_labels = None
depends_on = None
def upgrade():
"""Add invoices_zugferd_pdf column to settings table"""
from sqlalchemy import inspect
bind = op.get_bind()
inspector = inspect(bind)
existing_tables = inspector.get_table_names()
if "settings" not in existing_tables:
return
settings_columns = {c["name"] for c in inspector.get_columns("settings")}
if "invoices_zugferd_pdf" in settings_columns:
print("✓ Column invoices_zugferd_pdf already exists in settings table")
return
try:
op.add_column(
"settings",
sa.Column("invoices_zugferd_pdf", sa.Boolean(), nullable=False, server_default=sa.false()),
)
print("✓ Added invoices_zugferd_pdf column to settings table")
except Exception as e:
error_msg = str(e)
if "already exists" in error_msg.lower() or "duplicate" in error_msg.lower():
print("✓ Column invoices_zugferd_pdf already exists in settings table (detected via error)")
else:
print(f"✗ Error adding invoices_zugferd_pdf column: {e}")
raise
def downgrade():
"""Remove invoices_zugferd_pdf column from settings table"""
from sqlalchemy import inspect
bind = op.get_bind()
inspector = inspect(bind)
existing_tables = inspector.get_table_names()
if "settings" not in existing_tables:
return
settings_columns = {c["name"] for c in inspector.get_columns("settings")}
if "invoices_zugferd_pdf" not in settings_columns:
print("⊘ Column invoices_zugferd_pdf does not exist in settings table, skipping")
return
try:
op.drop_column("settings", "invoices_zugferd_pdf")
print("✓ Dropped invoices_zugferd_pdf column from settings table")
except Exception as e:
error_msg = str(e)
if "does not exist" in error_msg.lower() or "no such column" in error_msg.lower():
print("⊘ Column invoices_zugferd_pdf does not exist in settings table (detected via error)")
else:
print(f"⚠ Warning: Could not drop invoices_zugferd_pdf column: {e}")
+1
View File
@@ -40,6 +40,7 @@ WeasyPrint==60.2
pydyf==0.10.0
Pillow==10.4.0
reportlab==4.0.7
pikepdf>=8.0.0
# Background tasks
APScheduler==3.10.4
+131
View File
@@ -0,0 +1,131 @@
"""Tests for ZugFerd/Factur-X: embedding EN 16931 UBL in invoice PDFs."""
from datetime import date, timedelta
from decimal import Decimal
import io
import pytest
from app import db
from app.models import Client, Invoice, InvoiceItem, Project, User
from app.utils.zugferd import ZUGFERD_EMBEDDED_FILENAME, embed_zugferd_xml_in_pdf
@pytest.mark.unit
def test_embed_zugferd_xml_in_pdf_adds_attachment_and_xml_content(app):
"""Embed step adds ZUGFeRD-invoice.xml to PDF and XML contains invoice data."""
try:
import pikepdf
except ImportError:
pytest.skip("pikepdf not installed")
with app.app_context():
user = User(username="zugferduser", role="user", email="zugferd@example.com")
user.is_active = True
user.set_password("password123")
db.session.add(user)
client = Client(name="ZugFerd Client", email="client@example.com", address="Addr 1")
client.set_custom_field("peppol_endpoint_id", "9915:DE123456789")
client.set_custom_field("peppol_scheme_id", "9915")
db.session.add(client)
db.session.commit()
project = Project(
name="ZugFerd Project",
client_id=client.id,
billable=True,
hourly_rate=Decimal("80.00"),
)
project.status = "active"
db.session.add(project)
db.session.commit()
inv = Invoice(
invoice_number="INV-ZUG-001",
project_id=project.id,
client_name=client.name,
client_id=client.id,
issue_date=date.today(),
due_date=date.today() + timedelta(days=30),
created_by=user.id,
currency_code="EUR",
subtotal=Decimal("100.00"),
tax_rate=Decimal("20.00"),
tax_amount=Decimal("20.00"),
total_amount=Decimal("120.00"),
)
db.session.add(inv)
db.session.add(
InvoiceItem(
invoice_id=inv.id,
description="Consulting",
quantity=Decimal("1"),
unit_price=Decimal("100.00"),
total_amount=Decimal("100.00"),
)
)
db.session.commit()
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
if not getattr(settings, "company_name", None):
settings.company_name = "Test Company"
if not getattr(settings, "peppol_sender_endpoint_id", None):
settings.peppol_sender_endpoint_id = "9915:BE111111111"
if not getattr(settings, "peppol_sender_scheme_id", None):
settings.peppol_sender_scheme_id = "9915"
db.session.commit()
# Minimal valid PDF (one blank page)
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 = embed_zugferd_xml_in_pdf(pdf_bytes, inv, settings)
assert err is None
assert len(out_bytes) > len(pdf_bytes)
# Open result and check embedded file
result = pikepdf.open(io.BytesIO(out_bytes))
assert ZUGFERD_EMBEDDED_FILENAME in result.attachments
attached = result.attachments[ZUGFERD_EMBEDDED_FILENAME].get_file()
xml_content = attached.read().decode("utf-8")
result.close()
assert "<Invoice" in xml_content or "Invoice" in xml_content
assert "INV-ZUG-001" in xml_content
assert "120" in xml_content
@pytest.mark.unit
def test_embed_zugferd_returns_original_pdf_on_embed_failure(app):
"""When embedding fails (e.g. invalid PDF), return original bytes and error message."""
with app.app_context():
from types import SimpleNamespace
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
# Minimal invoice-like object; build_peppol_ubl_invoice_xml only needs a few attrs
inv = SimpleNamespace(
id=1,
invoice_number="INV-X",
issue_date=date.today(),
due_date=date.today(),
currency_code="EUR",
subtotal=Decimal("0"),
tax_rate=Decimal("0"),
tax_amount=Decimal("0"),
total_amount=Decimal("0"),
notes=None,
buyer_reference=None,
project=None,
client=None,
items=[],
expenses=[],
extra_goods=[],
)
invalid_pdf_bytes = b"not a valid pdf"
out_bytes, err = embed_zugferd_xml_in_pdf(invalid_pdf_bytes, inv, settings)
assert err is not None
assert out_bytes == invalid_pdf_bytes