diff --git a/docs/api-guide.md b/docs/api-guide.md
index b233d88..7a27f8c 100644
--- a/docs/api-guide.md
+++ b/docs/api-guide.md
@@ -107,6 +107,13 @@ Use this group for password protection, permissions, signatures, and standards.
- Archival/print conformance:
[convert_to_pdfa][pdfrest.PdfRestClient.convert_to_pdfa],
[convert_to_pdfx][pdfrest.PdfRestClient.convert_to_pdfx]
+- Electronic invoices:
+ [convert_invoice_xml_to_zugferd_pdf][pdfrest.PdfRestClient.convert_invoice_xml_to_zugferd_pdf]
+ generates a PDF/A-3 invoice from CII XML, while
+ [convert_pdf_to_zugferd_pdf][pdfrest.PdfRestClient.convert_pdf_to_zugferd_pdf]
+ embeds CII XML into an existing matching PDF. The latter preserves the PDF by
+ default; set `allow_regeneration=True` only when the service may replace a
+ mismatched PDF with a newly rendered invoice.
## PDF cleanup and rendering normalization
@@ -130,6 +137,8 @@ Use this group when the user starts with non-PDF content or needs downstream
formats.
- Into PDF:
+ [convert_invoice_xml_to_zugferd_pdf][pdfrest.PdfRestClient.convert_invoice_xml_to_zugferd_pdf],
+ [convert_pdf_to_zugferd_pdf][pdfrest.PdfRestClient.convert_pdf_to_zugferd_pdf],
[convert_office_to_pdf][pdfrest.PdfRestClient.convert_office_to_pdf],
[convert_postscript_to_pdf][pdfrest.PdfRestClient.convert_postscript_to_pdf],
[convert_email_to_pdf][pdfrest.PdfRestClient.convert_email_to_pdf],
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 5b55afd..d46ac4e 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -158,6 +158,11 @@ to the API guide and API reference for exact method names and options.
- Apply an uploaded `.joboptions` Distiller profile when converting PostScript
or EPS with `convert_postscript_to_pdf`
- Convert to standards formats (`PDF/A`, `PDF/X`)
+- Create validated ZUGFeRD or Factur-X PDF/A-3 invoices from uploaded CII XML
+ with ``convert_invoice_xml_to_zugferd_pdf`` and optional logo/render controls
+- Embed CII XML into a matching PDF with ``convert_pdf_to_zugferd_pdf``;
+ preserve the supplied PDF by default or explicitly permit regeneration when
+ the PDF and invoice do not match
### Extraction and Analysis
diff --git a/examples/README.md b/examples/README.md
index 39bf5ee..12a6744 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -35,6 +35,11 @@ supported interpreter matrix.
synchronously convert selected PDF pages and verify the PostScript output.
- `examples/convert_pdf_to_postscript/async_convert_pdf_to_postscript_example.py`
– asynchronously convert a PDF page and verify the PostScript output.
+- `examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py`
+ – create a formatted ZUGFeRD PDF/A-3 invoice from uploaded CII XML.
+- `examples/convert_pdf_to_zugferd_pdf/convert_pdf_to_zugferd_pdf_example.py` –
+ combine an uploaded PDF with CII XML and explicitly permit replacement when
+ the visual PDF does not match the invoice.
- `examples/delete/delete_example.py` – demonstrate file deletion (sync + async
variants).
- `examples/extract_text/extract_pdf_text_example.py` – run `extract_pdf_text`
diff --git a/examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py b/examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py
new file mode 100644
index 0000000..6cc262f
--- /dev/null
+++ b/examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py
@@ -0,0 +1,72 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["pdfrest", "python-dotenv"]
+# ///
+"""Create a branded ZUGFeRD PDF/A-3 invoice from CII XML.
+
+This sample demonstrates how to:
+
+1. Upload the checked-in invoice XML resource.
+2. Generate a ZUGFeRD PDF with typed appearance and locale options.
+3. Download and verify the generated PDF.
+4. Print the result metadata and input resource IDs.
+
+Set ``PDFREST_API_KEY`` and optionally ``PDFREST_BASE_URL`` for a self-hosted
+deployment, then run from the repository root with
+``uv run examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py``.
+The required XML input is included in the repository.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+from pdfrest import PdfRestClient
+from pdfrest.types import PdfZugferdRenderOptions
+
+RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources"
+INVOICE_PATH = RESOURCE_DIRECTORY / "zugferd-minimum-invoice.xml"
+
+
+def convert_invoice_xml_to_zugferd_pdf() -> None:
+ """Upload invoice XML, create the PDF/A-3 invoice, and verify it."""
+ load_dotenv()
+ base_url = os.getenv("PDFREST_BASE_URL")
+ render_options = PdfZugferdRenderOptions(
+ font="courier",
+ bold_font="courierbold",
+ accent_color=(20, 64, 115),
+ locale="de-DE",
+ label_language="en",
+ currency_display="symbol",
+ date_format="dd.MM.yyyy",
+ decimal_precision=2,
+ footer_message="Thank you for your business.",
+ )
+
+ with PdfRestClient(base_url=base_url) as client:
+ invoice = client.files.create_from_paths(INVOICE_PATH)[0]
+ response = client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ render_options=render_options,
+ output="zugferd-invoice",
+ )
+ pdf_bytes = client.files.read_bytes(response.output_file)
+
+ if not pdf_bytes.startswith(b"%PDF-"):
+ msg = "The generated resource is not a PDF."
+ raise RuntimeError(msg)
+
+ output = response.output_file
+ print(f"Created: {output.name}")
+ print(f"MIME type: {output.type}")
+ print(f"Size: {output.size} bytes")
+ print(f"Input IDs: {', '.join(response.input_ids)}")
+ print(f"Download URL: {output.url}")
+
+
+if __name__ == "__main__": # pragma: no cover - manual example
+ convert_invoice_xml_to_zugferd_pdf()
diff --git a/examples/convert_pdf_to_zugferd_pdf/convert_pdf_to_zugferd_pdf_example.py b/examples/convert_pdf_to_zugferd_pdf/convert_pdf_to_zugferd_pdf_example.py
new file mode 100644
index 0000000..7b5e48d
--- /dev/null
+++ b/examples/convert_pdf_to_zugferd_pdf/convert_pdf_to_zugferd_pdf_example.py
@@ -0,0 +1,63 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["pdfrest", "python-dotenv"]
+# ///
+"""Create a ZUGFeRD PDF/A-3 invoice from a visual PDF and CII XML.
+
+This sample demonstrates how to:
+
+1. Upload the checked-in PDF and invoice XML resources.
+2. Combine them while explicitly allowing a regenerated visual PDF when the
+ supplied PDF does not match the invoice data.
+3. Download and verify the resulting ZUGFeRD PDF.
+4. Print the result metadata and input resource IDs.
+
+Set ``PDFREST_API_KEY`` and optionally ``PDFREST_BASE_URL`` for a self-hosted
+deployment, then run from the repository root with
+``uv run examples/convert_pdf_to_zugferd_pdf/convert_pdf_to_zugferd_pdf_example.py``.
+The required inputs are included in the repository.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+from pdfrest import PdfRestClient
+
+RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources"
+PDF_PATH = RESOURCE_DIRECTORY / "report.pdf"
+INVOICE_PATH = RESOURCE_DIRECTORY / "zugferd-minimum-invoice.xml"
+
+
+def convert_pdf_to_zugferd_pdf() -> None:
+ """Upload PDF/XML inputs, create the PDF/A-3 invoice, and verify it."""
+ load_dotenv()
+ base_url = os.getenv("PDFREST_BASE_URL")
+
+ with PdfRestClient(base_url=base_url) as client:
+ pdf, invoice = client.files.create_from_paths([PDF_PATH, INVOICE_PATH])
+ response = client.convert_pdf_to_zugferd_pdf(
+ pdf,
+ invoice=invoice,
+ allow_regeneration=True,
+ output="zugferd-from-pdf",
+ )
+ pdf_bytes = client.files.read_bytes(response.output_file)
+
+ if not pdf_bytes.startswith(b"%PDF-"):
+ msg = "The generated resource is not a PDF."
+ raise RuntimeError(msg)
+
+ output = response.output_file
+ print(f"Created: {output.name}")
+ print(f"MIME type: {output.type}")
+ print(f"Size: {output.size} bytes")
+ print(f"Input IDs: {', '.join(response.input_ids)}")
+ print(f"Download URL: {output.url}")
+
+
+if __name__ == "__main__": # pragma: no cover - manual example
+ convert_pdf_to_zugferd_pdf()
diff --git a/examples/resources/zugferd-minimum-invoice.xml b/examples/resources/zugferd-minimum-invoice.xml
new file mode 100644
index 0000000..9fd3a95
--- /dev/null
+++ b/examples/resources/zugferd-minimum-invoice.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+ urn:factur-x.eu:1p0:minimum
+
+
+
+ 471102
+ 380
+
+ 20200305
+
+
+
+
+
+ Lieferant GmbH
+
+ DE
+
+
+ 201/113/40209
+
+
+ DE123456789
+
+
+
+ Kunden AG Frankreich
+
+
+
+
+ EUR
+
+ 198.00
+ 37.62
+ 235.62
+ 235.62
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index a280d6f..07937e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "pdfrest"
-version = "1.4.1"
+version = "1.5.0"
description = "Python client library for interacting with the pdfRest API"
readme = { file = "README.md", content-type = "text/markdown" }
license = "Apache-2.0"
diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py
index 7849669..f75e2c8 100644
--- a/src/pdfrest/client.py
+++ b/src/pdfrest/client.py
@@ -112,6 +112,7 @@
PdfImageWatermarkPayload,
PdfImportFormDataPayload,
PdfInfoPayload,
+ PdfInvoiceXmlToZugferdPayload,
PdfLinearizePayload,
PdfMergePayload,
PdfRasterizePayload,
@@ -127,6 +128,7 @@
PdfToPdfxPayload,
PdfToPowerpointPayload,
PdfToWordPayload,
+ PdfToZugferdPayload,
PdfUnrestrictPayload,
PdfXfaToAcroformsPayload,
PngPdfRestPayload,
@@ -178,6 +180,7 @@
PdfStructuredTextTableStyle,
PdfTextColor,
PdfXType,
+ PdfZugferdRenderOptions,
PngColorModel,
PostScriptLevel,
SummaryFormat,
@@ -4615,6 +4618,120 @@ def sign_pdf(
timeout=timeout,
)
+ def convert_invoice_xml_to_zugferd_pdf(
+ self,
+ invoice: PdfRestFile | Sequence[PdfRestFile],
+ *,
+ logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ render_options: PdfZugferdRenderOptions | None = None,
+ output: str | None = None,
+ extra_query: Query | None = None,
+ extra_headers: AnyMapping | None = None,
+ extra_body: Body | None = None,
+ timeout: TimeoutTypes | None = None,
+ ) -> PdfRestFileBasedResponse:
+ """Convert CII invoice XML into a ZUGFeRD or Factur-X PDF/A-3 invoice.
+
+ Args:
+ invoice: Uploaded CII invoice XML resource.
+ logo: Optional uploaded PNG or JPEG logo for the generated PDF.
+ render_options: Optional appearance and regional-formatting options.
+ output: Output filename prefix used by pdfRest when creating the
+ ZUGFeRD PDF.
+ extra_query: Additional query parameters merged into the request.
+ extra_headers: Additional HTTP headers merged into the request.
+ extra_body: Additional request body fields merged into the payload.
+ timeout: Request timeout override for this call.
+
+ Returns:
+ Validated file-based response containing the generated PDF/A-3 file.
+
+ Raises:
+ PdfRestError: If request execution fails at the client or API layer.
+ ValidationError: If local payload validation fails before sending.
+ """
+ payload: dict[str, Any] = {"invoice": invoice}
+ if logo is not None:
+ payload["logo"] = logo
+ if render_options is not None:
+ payload["render_options"] = render_options
+ if output is not None:
+ payload["output"] = output
+
+ return self._post_file_operation(
+ endpoint="/zugferd-pdf",
+ payload=payload,
+ payload_model=PdfInvoiceXmlToZugferdPayload,
+ extra_query=extra_query,
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
+ def convert_pdf_to_zugferd_pdf(
+ self,
+ pdf: PdfRestFile | Sequence[PdfRestFile],
+ *,
+ invoice: PdfRestFile | Sequence[PdfRestFile],
+ allow_regeneration: bool = False,
+ logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ render_options: PdfZugferdRenderOptions | None = None,
+ output: str | None = None,
+ extra_query: Query | None = None,
+ extra_headers: AnyMapping | None = None,
+ extra_body: Body | None = None,
+ timeout: TimeoutTypes | None = None,
+ ) -> PdfRestFileBasedResponse:
+ """Convert a supplied PDF and CII XML into a validated ZUGFeRD PDF.
+
+ The supplied PDF is preserved by default. When ``allow_regeneration``
+ is true, pdfRest may replace it if matching cannot be confirmed or
+ complete visual confirmation is required. Appearance options apply only
+ when a replacement is actually generated; use
+ ``convert_invoice_xml_to_zugferd_pdf`` to guarantee newly styled visuals.
+
+ Args:
+ pdf: Uploaded PDF whose visuals should be preserved when possible.
+ invoice: Uploaded CII invoice XML resource.
+ allow_regeneration: Permit pdfRest to replace the supplied PDF.
+ logo: Optional uploaded PNG or JPEG logo used only for replacement.
+ render_options: Optional appearance and regional-formatting options
+ used only for replacement.
+ output: Output filename prefix used by pdfRest.
+ extra_query: Additional query parameters merged into the request.
+ extra_headers: Additional HTTP headers merged into the request.
+ extra_body: Additional request body fields merged into the payload.
+ timeout: Request timeout override for this call.
+
+ Returns:
+ Validated file-based response containing the ZUGFeRD PDF/A-3 file.
+
+ Raises:
+ PdfRestError: If request execution fails at the client or API layer.
+ ValidationError: If local payload validation fails before sending.
+ """
+ payload: dict[str, Any] = {
+ "invoice": invoice,
+ "pdf": pdf,
+ "regenerate_pdf": allow_regeneration,
+ }
+ if logo is not None:
+ payload["logo"] = logo
+ if render_options is not None:
+ payload["render_options"] = render_options
+ if output is not None:
+ payload["output"] = output
+
+ return self._post_file_operation(
+ endpoint="/zugferd-pdf",
+ payload=payload,
+ payload_model=PdfToZugferdPayload,
+ extra_query=extra_query,
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
def blank_pdf(
self,
*,
@@ -8151,6 +8268,122 @@ async def sign_pdf(
timeout=timeout,
)
+ async def convert_invoice_xml_to_zugferd_pdf(
+ self,
+ invoice: PdfRestFile | Sequence[PdfRestFile],
+ *,
+ logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ render_options: PdfZugferdRenderOptions | None = None,
+ output: str | None = None,
+ extra_query: Query | None = None,
+ extra_headers: AnyMapping | None = None,
+ extra_body: Body | None = None,
+ timeout: TimeoutTypes | None = None,
+ ) -> PdfRestFileBasedResponse:
+ """Convert CII XML into a ZUGFeRD PDF/A-3 invoice asynchronously.
+
+ This is the asynchronous variant of
+ [PdfRestClient.convert_invoice_xml_to_zugferd_pdf][pdfrest.PdfRestClient.convert_invoice_xml_to_zugferd_pdf].
+
+ Args:
+ invoice: Uploaded CII invoice XML resource.
+ logo: Optional uploaded PNG or JPEG logo for the generated PDF.
+ render_options: Optional appearance and regional-formatting options.
+ output: Output filename prefix used by pdfRest when creating the
+ ZUGFeRD PDF.
+ extra_query: Additional query parameters merged into the request.
+ extra_headers: Additional HTTP headers merged into the request.
+ extra_body: Additional request body fields merged into the payload.
+ timeout: Request timeout override for this call.
+
+ Returns:
+ Validated file-based response containing the generated PDF/A-3 file.
+
+ Raises:
+ PdfRestError: If request execution fails at the client or API layer.
+ ValidationError: If local payload validation fails before sending.
+ """
+ payload: dict[str, Any] = {"invoice": invoice}
+ if logo is not None:
+ payload["logo"] = logo
+ if render_options is not None:
+ payload["render_options"] = render_options
+ if output is not None:
+ payload["output"] = output
+
+ return await self._post_file_operation(
+ endpoint="/zugferd-pdf",
+ payload=payload,
+ payload_model=PdfInvoiceXmlToZugferdPayload,
+ extra_query=extra_query,
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
+ async def convert_pdf_to_zugferd_pdf(
+ self,
+ pdf: PdfRestFile | Sequence[PdfRestFile],
+ *,
+ invoice: PdfRestFile | Sequence[PdfRestFile],
+ allow_regeneration: bool = False,
+ logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ render_options: PdfZugferdRenderOptions | None = None,
+ output: str | None = None,
+ extra_query: Query | None = None,
+ extra_headers: AnyMapping | None = None,
+ extra_body: Body | None = None,
+ timeout: TimeoutTypes | None = None,
+ ) -> PdfRestFileBasedResponse:
+ """Convert a supplied PDF and CII XML into a ZUGFeRD PDF asynchronously.
+
+ This is the asynchronous variant of
+ [PdfRestClient.convert_pdf_to_zugferd_pdf][pdfrest.PdfRestClient.convert_pdf_to_zugferd_pdf].
+ The supplied PDF is preserved by default. With regeneration permission,
+ appearance options apply only if pdfRest actually creates a replacement.
+
+ Args:
+ pdf: Uploaded PDF whose visuals should be preserved when possible.
+ invoice: Uploaded CII invoice XML resource.
+ allow_regeneration: Permit pdfRest to replace the supplied PDF.
+ logo: Optional uploaded PNG or JPEG logo used only for replacement.
+ render_options: Optional appearance and regional-formatting options
+ used only for replacement.
+ output: Output filename prefix used by pdfRest.
+ extra_query: Additional query parameters merged into the request.
+ extra_headers: Additional HTTP headers merged into the request.
+ extra_body: Additional request body fields merged into the payload.
+ timeout: Request timeout override for this call.
+
+ Returns:
+ Validated file-based response containing the ZUGFeRD PDF/A-3 file.
+
+ Raises:
+ PdfRestError: If request execution fails at the client or API layer.
+ ValidationError: If local payload validation fails before sending.
+ """
+ payload: dict[str, Any] = {
+ "invoice": invoice,
+ "pdf": pdf,
+ "regenerate_pdf": allow_regeneration,
+ }
+ if logo is not None:
+ payload["logo"] = logo
+ if render_options is not None:
+ payload["render_options"] = render_options
+ if output is not None:
+ payload["output"] = output
+
+ return await self._post_file_operation(
+ endpoint="/zugferd-pdf",
+ payload=payload,
+ payload_model=PdfToZugferdPayload,
+ extra_query=extra_query,
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
async def blank_pdf(
self,
*,
diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py
index e32014a..11c0703 100644
--- a/src/pdfrest/models/_internal.py
+++ b/src/pdfrest/models/_internal.py
@@ -5,7 +5,7 @@
from pathlib import PurePath
from typing import Annotated, Any, Generic, Literal, TypeVar, cast, get_args
-from langcodes import tag_is_valid
+from langcodes import standardize_tag, tag_is_valid
from pydantic import (
AfterValidator,
AliasChoices,
@@ -49,6 +49,9 @@
PdfStructuredTextPageSize,
PdfStructuredTextTextAlignment,
PdfXType,
+ PdfZugferdCurrencyDisplay,
+ PdfZugferdLocale,
+ PdfZugferdLogoCorner,
PostScriptLevel,
SummaryFormat,
SummaryOutputFormat,
@@ -329,36 +332,22 @@ def _int_to_string(value: Any) -> Any:
"'es-419'). See documentation for recommended formats."
)
+_SIMPLE_LANGUAGE_TAG_PATTERN = (
+ r"[A-Za-z]{2,3}(?:-(?:[A-Za-z]{4}|[A-Za-z]{2}|[0-9]{3}))?"
+)
-def _validate_output_language(value: str) -> str:
- if not value:
- raise ValueError(_OUTPUT_LANGUAGE_ERROR)
+def _validate_simple_language_tag(value: str, *, error_msg: str) -> str:
trimmed = value.strip()
- if not trimmed:
- raise ValueError(_OUTPUT_LANGUAGE_ERROR)
-
- segments = trimmed.split("-")
- if len(segments) > 2:
- raise ValueError(_OUTPUT_LANGUAGE_ERROR)
-
- language = segments[0]
- if not re.fullmatch(r"[A-Za-z]{2,3}", language):
- raise ValueError(_OUTPUT_LANGUAGE_ERROR)
-
- if len(segments) == 2:
- subtag = segments[1]
- if not (
- re.fullmatch(r"[A-Za-z]{4}", subtag)
- or re.fullmatch(r"[A-Za-z]{2}", subtag)
- or re.fullmatch(r"[0-9]{3}", subtag)
- ):
- raise ValueError(_OUTPUT_LANGUAGE_ERROR)
+ if not re.fullmatch(_SIMPLE_LANGUAGE_TAG_PATTERN, trimmed) or not tag_is_valid(
+ trimmed
+ ):
+ raise ValueError(error_msg)
+ return trimmed
- if not tag_is_valid(trimmed):
- raise ValueError(_OUTPUT_LANGUAGE_ERROR)
- return trimmed
+def _validate_output_language(value: str) -> str:
+ return _validate_simple_language_tag(value, error_msg=_OUTPUT_LANGUAGE_ERROR)
_PAGE_MARGIN_REGEX = r"^(?:\d+(?:\.\d+)?)(?:mm|in)$"
@@ -1545,6 +1534,320 @@ class ExtractImagesPayload(BaseModel):
CmykChannel = Annotated[int, Field(ge=0, le=100)]
+def _canonicalize_zugferd_label_language(value: str) -> str:
+ if not tag_is_valid(value):
+ msg = "label_language must be a valid BCP 47 language tag."
+ raise ValueError(msg)
+ primary_language = standardize_tag(value).split("-", maxsplit=1)[0].casefold()
+ if primary_language not in {"en", "de", "fr"}:
+ msg = "label_language must use English, German, or French."
+ raise ValueError(msg)
+ return primary_language
+
+
+def _validate_zugferd_font(value: str) -> str:
+ if value.casefold() == "webdings":
+ msg = "ZUGFeRD invoice fonts must be text-capable; Webdings is unsupported."
+ raise ValueError(msg)
+ return value
+
+
+_ZugferdFontName = Annotated[
+ str,
+ StringConstraints(strip_whitespace=True, min_length=1, max_length=200),
+ AfterValidator(_validate_zugferd_font),
+]
+_ZugferdLabelLanguage = Annotated[
+ str,
+ StringConstraints(
+ strip_whitespace=True,
+ min_length=2,
+ max_length=8,
+ pattern=rf"^{_SIMPLE_LANGUAGE_TAG_PATTERN}$",
+ ),
+ AfterValidator(_canonicalize_zugferd_label_language),
+]
+
+
+class _PdfZugferdRenderOptions(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ logo_corner: PdfZugferdLogoCorner | None = None
+ font: _ZugferdFontName | None = None
+ bold_font: _ZugferdFontName | None = None
+ text_color_rgb: Annotated[
+ tuple[RgbChannel, RgbChannel, RgbChannel] | None,
+ Field(
+ validation_alias="text_color",
+ serialization_alias="text_color_rgb",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="text_color",
+ expected_channel_count=3,
+ alternate_channel_count=4,
+ )
+ ),
+ ] = None
+ text_color_cmyk: Annotated[
+ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None,
+ Field(
+ validation_alias="text_color",
+ serialization_alias="text_color_cmyk",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="text_color",
+ expected_channel_count=4,
+ alternate_channel_count=3,
+ )
+ ),
+ ] = None
+ muted_text_color_rgb: Annotated[
+ tuple[RgbChannel, RgbChannel, RgbChannel] | None,
+ Field(
+ validation_alias="muted_text_color",
+ serialization_alias="muted_text_color_rgb",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="muted_text_color",
+ expected_channel_count=3,
+ alternate_channel_count=4,
+ )
+ ),
+ ] = None
+ muted_text_color_cmyk: Annotated[
+ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None,
+ Field(
+ validation_alias="muted_text_color",
+ serialization_alias="muted_text_color_cmyk",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="muted_text_color",
+ expected_channel_count=4,
+ alternate_channel_count=3,
+ )
+ ),
+ ] = None
+ accent_color_rgb: Annotated[
+ tuple[RgbChannel, RgbChannel, RgbChannel] | None,
+ Field(
+ validation_alias="accent_color",
+ serialization_alias="accent_color_rgb",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="accent_color",
+ expected_channel_count=3,
+ alternate_channel_count=4,
+ )
+ ),
+ ] = None
+ accent_color_cmyk: Annotated[
+ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None,
+ Field(
+ validation_alias="accent_color",
+ serialization_alias="accent_color_cmyk",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="accent_color",
+ expected_channel_count=4,
+ alternate_channel_count=3,
+ )
+ ),
+ ] = None
+ border_color_rgb: Annotated[
+ tuple[RgbChannel, RgbChannel, RgbChannel] | None,
+ Field(
+ validation_alias="border_color",
+ serialization_alias="border_color_rgb",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="border_color",
+ expected_channel_count=3,
+ alternate_channel_count=4,
+ )
+ ),
+ ] = None
+ border_color_cmyk: Annotated[
+ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None,
+ Field(
+ validation_alias="border_color",
+ serialization_alias="border_color_cmyk",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="border_color",
+ expected_channel_count=4,
+ alternate_channel_count=3,
+ )
+ ),
+ ] = None
+ table_header_color_rgb: Annotated[
+ tuple[RgbChannel, RgbChannel, RgbChannel] | None,
+ Field(
+ validation_alias="table_header_color",
+ serialization_alias="table_header_color_rgb",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="table_header_color",
+ expected_channel_count=3,
+ alternate_channel_count=4,
+ )
+ ),
+ ] = None
+ table_header_color_cmyk: Annotated[
+ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None,
+ Field(
+ validation_alias="table_header_color",
+ serialization_alias="table_header_color_cmyk",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="table_header_color",
+ expected_channel_count=4,
+ alternate_channel_count=3,
+ )
+ ),
+ ] = None
+ table_alternate_color_rgb: Annotated[
+ tuple[RgbChannel, RgbChannel, RgbChannel] | None,
+ Field(
+ validation_alias="table_alternate_color",
+ serialization_alias="table_alternate_color_rgb",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="table_alternate_color",
+ expected_channel_count=3,
+ alternate_channel_count=4,
+ )
+ ),
+ ] = None
+ table_alternate_color_cmyk: Annotated[
+ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None,
+ Field(
+ validation_alias="table_alternate_color",
+ serialization_alias="table_alternate_color_cmyk",
+ default=None,
+ ),
+ BeforeValidator(
+ _route_color_by_channel_count(
+ color_name="table_alternate_color",
+ expected_channel_count=4,
+ alternate_channel_count=3,
+ )
+ ),
+ ] = None
+ footer_message: Annotated[str | None, Field(max_length=500)] = None
+ locale: PdfZugferdLocale | None = None
+ label_language: _ZugferdLabelLanguage = None # pyright: ignore[reportAssignmentType]
+ currency_display: PdfZugferdCurrencyDisplay | None = None
+ date_format: str | None = None
+ decimal_precision: Annotated[int | None, Field(ge=0, le=4)] = None
+
+
+_ZugferdInvoiceResource = Annotated[
+ list[PdfRestFile],
+ Field(min_length=1, max_length=1, serialization_alias="id"),
+ BeforeValidator(_ensure_list),
+ AfterValidator(
+ _allowed_mime_types(
+ "application/xml",
+ "text/xml",
+ error_msg="Invoice must be an XML file",
+ )
+ ),
+ PlainSerializer(_serialize_as_first_file_id),
+]
+_ZugferdPdfResource = Annotated[
+ list[PdfRestFile],
+ Field(min_length=1, max_length=1, serialization_alias="pdf_id"),
+ BeforeValidator(_ensure_list),
+ AfterValidator(
+ _allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
+ ),
+ PlainSerializer(_serialize_as_first_file_id),
+]
+_ZugferdLogoFiles = Annotated[
+ list[PdfRestFile],
+ Field(min_length=1, max_length=1),
+ BeforeValidator(_ensure_list),
+ AfterValidator(
+ _allowed_mime_types(
+ "image/png",
+ "image/jpeg",
+ error_msg="Logo must be a PNG or JPEG file",
+ )
+ ),
+]
+_ZugferdOutput = Annotated[
+ str,
+ Field(min_length=1),
+ AfterValidator(_validate_output_prefix),
+]
+
+
+class _BasePdfZugferdPayload(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ invoice: _ZugferdInvoiceResource
+
+
+class PdfInvoiceXmlToZugferdPayload(_BasePdfZugferdPayload):
+ """Adapt uploaded invoice XML into a generated ZUGFeRD PDF request."""
+
+ logo: Annotated[
+ _ZugferdLogoFiles | None,
+ Field(serialization_alias="logo_id"),
+ PlainSerializer(_serialize_as_first_file_id),
+ ] = None
+ render_options: _PdfZugferdRenderOptions | None = None
+ output: _ZugferdOutput | None = None
+
+
+class PdfToZugferdPayload(_BasePdfZugferdPayload):
+ """Adapt uploaded PDF and invoice resources into a ZUGFeRD request."""
+
+ pdf: _ZugferdPdfResource
+ logo: Annotated[
+ _ZugferdLogoFiles | None,
+ Field(serialization_alias="logo_id"),
+ PlainSerializer(_serialize_as_first_file_id),
+ ] = None
+ render_options: _PdfZugferdRenderOptions | None = None
+ regenerate_pdf: bool = False
+ output: _ZugferdOutput | None = None
+
+ @model_validator(mode="after")
+ def _validate_regeneration_options(self) -> PdfToZugferdPayload:
+ if not self.regenerate_pdf and (
+ self.logo is not None or self.render_options is not None
+ ):
+ msg = (
+ "logo and render_options require allow_regeneration=True because "
+ "they apply only when pdfRest replaces the supplied PDF."
+ )
+ raise ValueError(msg)
+ return self
+
+
class PdfLiteralRedactionModel(BaseModel):
type: Literal["literal"]
value: Annotated[str, Field(min_length=1)]
diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py
index 48b9ffa..af34d35 100644
--- a/src/pdfrest/types/__init__.py
+++ b/src/pdfrest/types/__init__.py
@@ -30,6 +30,7 @@
PdfConversionLocale,
PdfCustomPageSize,
PdfInfoQuery,
+ PdfKnownTextFont,
PdfMergeInput,
PdfMergeSource,
PdfPageOrientation,
@@ -63,6 +64,13 @@
PdfStructuredTextTextAlignment,
PdfTextColor,
PdfXType,
+ PdfZugferdCurrencyDisplay,
+ PdfZugferdFontName,
+ PdfZugferdKnownFont,
+ PdfZugferdLabelLanguage,
+ PdfZugferdLocale,
+ PdfZugferdLogoCorner,
+ PdfZugferdRenderOptions,
PngColorModel,
PostScriptLevel,
SummaryFormat,
@@ -104,6 +112,7 @@
"PdfConversionLocale",
"PdfCustomPageSize",
"PdfInfoQuery",
+ "PdfKnownTextFont",
"PdfMergeInput",
"PdfMergeSource",
"PdfPageOrientation",
@@ -137,6 +146,13 @@
"PdfStructuredTextTextAlignment",
"PdfTextColor",
"PdfXType",
+ "PdfZugferdCurrencyDisplay",
+ "PdfZugferdFontName",
+ "PdfZugferdKnownFont",
+ "PdfZugferdLabelLanguage",
+ "PdfZugferdLocale",
+ "PdfZugferdLogoCorner",
+ "PdfZugferdRenderOptions",
"PngColorModel",
"PostScriptLevel",
"SummaryFormat",
diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py
index e74e433..f152311 100644
--- a/src/pdfrest/types/public.py
+++ b/src/pdfrest/types/public.py
@@ -42,6 +42,7 @@
"PdfConversionLocale",
"PdfCustomPageSize",
"PdfInfoQuery",
+ "PdfKnownTextFont",
"PdfMergeInput",
"PdfMergeSource",
"PdfPageOrientation",
@@ -75,6 +76,13 @@
"PdfStructuredTextTextAlignment",
"PdfTextColor",
"PdfXType",
+ "PdfZugferdCurrencyDisplay",
+ "PdfZugferdFontName",
+ "PdfZugferdKnownFont",
+ "PdfZugferdLabelLanguage",
+ "PdfZugferdLocale",
+ "PdfZugferdLogoCorner",
+ "PdfZugferdRenderOptions",
"PngColorModel",
"PostScriptLevel",
"SummaryFormat",
@@ -159,44 +167,55 @@ class PdfRedactionInstruction(TypedDict):
PdfColor = PdfRGBColor | PdfCMYKColor
PdfTextColor = PdfColor
-PdfStructuredTextDataPresentation: TypeAlias = Literal["source", "hierarchy"]
-"""JSON/XML presentation accepted by structured document conversion helpers.
+PdfZugferdLogoCorner: TypeAlias = Literal[
+ "top-left", "top-right", "bottom-left", "bottom-right"
+]
+"""Logo placement used while rendering a ZUGFeRD invoice.
Accepted values:
-- `source`: Preserve JSON or XML syntax and indentation.
-- `hierarchy`: Render JSON or XML as a readable hierarchy.
+- `top-left`: Place the logo in the upper-left corner.
+- `top-right`: Place the logo in the upper-right corner.
+- `bottom-left`: Place the logo in the lower-left corner.
+- `bottom-right`: Place the logo in the lower-right corner.
"""
-PdfStructuredTextPageOrientation: TypeAlias = Literal["auto", "portrait", "landscape"]
-"""Page orientation accepted by structured document conversion helpers.
+PdfZugferdLocale: TypeAlias = Literal["de-DE", "fr-FR", "nl-NL", "en-GB"]
+"""Locale used to format values in a rendered ZUGFeRD invoice.
Accepted values:
-- `auto`: Let the converter choose an orientation appropriate for the content.
-- `portrait`: Use portrait page orientation.
-- `landscape`: Use landscape page orientation.
+- `de-DE`: Format values using German (Germany) conventions.
+- `fr-FR`: Format values using French (France) conventions.
+- `nl-NL`: Format values using Dutch (Netherlands) conventions.
+- `en-GB`: Format values using English (United Kingdom) conventions.
"""
-PdfStructuredTextPageSize: TypeAlias = Literal[
- "Letter", "Legal", "Ledger", "A3", "A4", "A5", "Tabloid"
-]
-"""Named page size for structured document conversion.
+PdfZugferdCurrencyDisplay: TypeAlias = Literal["code", "symbol", "name"]
+"""Currency representation used in a rendered ZUGFeRD invoice.
-For a custom size, use ``PdfStructuredTextPageSetup.width`` and ``height``.
+Accepted values:
+
+- `code`: Display the ISO currency code.
+- `symbol`: Display the currency symbol, falling back to its code when unknown.
+- `name`: Display the currency name.
+"""
+
+PdfZugferdLabelLanguage: TypeAlias = Literal["en", "de", "fr"] | str
+"""BCP 47 language tag used for generated ZUGFeRD invoice labels.
+
+The primary language must be English, German, or French. ISO 639-2 aliases
+(``eng``, ``deu``, and ``fra``) and region subtags such as ``en-US`` are
+accepted and canonicalized to the corresponding two-letter primary language.
Accepted values:
-- `Letter`: US letter paper, 612 by 792 PDF points.
-- `Legal`: US legal paper, 612 by 1008 PDF points.
-- `Ledger`: Ledger paper, 1224 by 792 PDF points.
-- `A3`: ISO A3 paper, 842 by 1191 PDF points.
-- `A4`: ISO A4 paper, 595 by 842 PDF points.
-- `A5`: ISO A5 paper, 420 by 595 PDF points.
-- `Tabloid`: Tabloid paper, 792 by 1224 PDF points.
+- `en`: Render English labels.
+- `de`: Render German labels.
+- `fr`: Render French labels.
"""
-PdfStructuredTextKnownFont: TypeAlias = Literal[
+PdfKnownTextFont: TypeAlias = Literal[
"andalemono",
"arial",
"arialitalic",
@@ -257,13 +276,12 @@ class PdfRedactionInstruction(TypedDict):
"verdanaitalic",
"verdanabold",
"verdanabolditalic",
- "webdings",
]
-"""Published pdfRest font names for structured document conversion.
+"""Published text-capable pdfRest font names.
-These are suggestions, not a closed service catalog. An installed font name
-outside this list may also be used through ``PdfStructuredTextFontName``.
-See the [pdfRest Font List](https://pdfrest.com/documentation/fontlist/).
+These are suggestions, not a closed service catalog. APIs that accept installed
+font names may also accept deployment-specific text-capable fonts. Symbol fonts
+such as Webdings are excluded from this shared readable-text catalog.
Accepted values:
@@ -327,6 +345,114 @@ class PdfRedactionInstruction(TypedDict):
- `verdanaitalic`: Verdana Italic.
- `verdanabold`: Verdana Bold.
- `verdanabolditalic`: Verdana Bold Italic.
+"""
+
+PdfZugferdKnownFont: TypeAlias = PdfKnownTextFont
+"""Published text-capable pdfRest font names for ZUGFeRD rendering.
+
+Accepted values are documented by ``PdfKnownTextFont``. These are suggestions,
+not a closed service catalog; ``PdfZugferdFontName`` also accepts other
+installed text-capable fonts.
+"""
+
+PdfZugferdFontName: TypeAlias = PdfZugferdKnownFont | str
+"""A published pdfRest font token or another installed text-capable font."""
+
+
+class PdfZugferdRenderOptions(TypedDict, total=False):
+ """Appearance and regional options for generated ZUGFeRD invoice PDFs.
+
+ Attributes:
+ logo_corner: Optional corner for an uploaded logo. Defaults to
+ ``top-left``.
+ font: Optional non-empty body-text font name, at most 200 characters.
+ bold_font: Optional non-empty emphasized-text font name, at most 200
+ characters.
+ text_color: Optional primary text color as RGB (0-255 channels) or CMYK
+ (0-100 percentage channels).
+ muted_text_color: Optional secondary text color as RGB or CMYK.
+ accent_color: Optional accent color as RGB or CMYK.
+ border_color: Optional table/rule border color as RGB or CMYK.
+ table_header_color: Optional table-header fill color as RGB or CMYK.
+ table_alternate_color: Optional alternating-row fill color as RGB or
+ CMYK.
+ footer_message: Optional footer text, at most 500 characters. ``None``
+ leaves the footer unset.
+ locale: Optional locale for value formatting. Defaults to ``en-GB``.
+ label_language: Optional BCP 47 label language whose primary subtag is
+ ``en``, ``de``, or ``fr``. Defaults to ``en``.
+ currency_display: Optional currency representation. Defaults to
+ ``code``.
+ date_format: Optional .NET date format that preserves the complete day,
+ month, and four-digit year. ``None`` uses the renderer default.
+ decimal_precision: Optional decimal places from 0 through 4. Defaults
+ to 2.
+ """
+
+ logo_corner: PdfZugferdLogoCorner
+ font: PdfZugferdFontName
+ bold_font: PdfZugferdFontName
+ text_color: PdfColor
+ muted_text_color: PdfColor
+ accent_color: PdfColor
+ border_color: PdfColor
+ table_header_color: PdfColor
+ table_alternate_color: PdfColor
+ footer_message: str | None
+ locale: PdfZugferdLocale
+ label_language: PdfZugferdLabelLanguage
+ currency_display: PdfZugferdCurrencyDisplay
+ date_format: str | None
+ decimal_precision: int
+
+
+PdfStructuredTextDataPresentation: TypeAlias = Literal["source", "hierarchy"]
+"""JSON/XML presentation accepted by structured document conversion helpers.
+
+Accepted values:
+
+- `source`: Preserve JSON or XML syntax and indentation.
+- `hierarchy`: Render JSON or XML as a readable hierarchy.
+"""
+
+PdfStructuredTextPageOrientation: TypeAlias = Literal["auto", "portrait", "landscape"]
+"""Page orientation accepted by structured document conversion helpers.
+
+Accepted values:
+
+- `auto`: Let the converter choose an orientation appropriate for the content.
+- `portrait`: Use portrait page orientation.
+- `landscape`: Use landscape page orientation.
+"""
+
+PdfStructuredTextPageSize: TypeAlias = Literal[
+ "Letter", "Legal", "Ledger", "A3", "A4", "A5", "Tabloid"
+]
+"""Named page size for structured document conversion.
+
+For a custom size, use ``PdfStructuredTextPageSetup.width`` and ``height``.
+
+Accepted values:
+
+- `Letter`: US letter paper, 612 by 792 PDF points.
+- `Legal`: US legal paper, 612 by 1008 PDF points.
+- `Ledger`: Ledger paper, 1224 by 792 PDF points.
+- `A3`: ISO A3 paper, 842 by 1191 PDF points.
+- `A4`: ISO A4 paper, 595 by 842 PDF points.
+- `A5`: ISO A5 paper, 420 by 595 PDF points.
+- `Tabloid`: Tabloid paper, 792 by 1224 PDF points.
+"""
+
+PdfStructuredTextKnownFont: TypeAlias = PdfKnownTextFont | Literal["webdings"]
+"""Published pdfRest font names for structured document conversion.
+
+This catalog combines every text-capable value documented by
+``PdfKnownTextFont`` with the structured-document-only symbol font.
+``PdfStructuredTextFontName`` also accepts deployment-specific installed fonts.
+
+Accepted values:
+
+- Every value documented by ``PdfKnownTextFont``: text-capable installed fonts.
- `webdings`: Webdings.
"""
diff --git a/tests/live/test_live_create_zugferd_pdf.py b/tests/live/test_live_create_zugferd_pdf.py
new file mode 100644
index 0000000..c5c1069
--- /dev/null
+++ b/tests/live/test_live_create_zugferd_pdf.py
@@ -0,0 +1,534 @@
+from __future__ import annotations
+
+from typing import TypedDict
+
+import pytest
+
+from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient
+from pdfrest.models import PdfRestFile
+from pdfrest.types import PdfZugferdRenderOptions
+
+from ..resources import get_test_resource_path
+
+
+class ZugferdResources(TypedDict):
+ invoice: PdfRestFile
+ logo: PdfRestFile
+ mismatched_pdf: PdfRestFile
+ matching_pdf: PdfRestFile
+
+
+ISOLATED_RENDER_OPTION_CASES = [
+ *(
+ pytest.param(
+ PdfZugferdRenderOptions(logo_corner=value),
+ f"logo-{value}",
+ id=f"logo-{value}",
+ )
+ for value in ("top-left", "top-right", "bottom-left", "bottom-right")
+ ),
+ *(
+ pytest.param(
+ PdfZugferdRenderOptions(locale=value),
+ f"locale-{value}",
+ id=f"locale-{value}",
+ )
+ for value in ("de-DE", "fr-FR", "nl-NL", "en-GB")
+ ),
+ *(
+ pytest.param(
+ PdfZugferdRenderOptions(label_language=value),
+ f"language-{value}",
+ id=f"language-{value}",
+ )
+ for value in ("en", "de", "fr")
+ ),
+ *(
+ pytest.param(
+ PdfZugferdRenderOptions(currency_display=value),
+ f"currency-{value}",
+ id=f"currency-{value}",
+ )
+ for value in ("code", "symbol", "name")
+ ),
+ pytest.param(
+ PdfZugferdRenderOptions(decimal_precision=0),
+ "precision-minimum",
+ id="precision-minimum",
+ ),
+ pytest.param(
+ PdfZugferdRenderOptions(decimal_precision=4),
+ "precision-maximum",
+ id="precision-maximum",
+ ),
+]
+
+INVALID_RENDER_OPTION_CASES = [
+ pytest.param(
+ {"logo_corner": "center"}, r"(?i)logo.corner|invalid", id="logo-corner"
+ ),
+ pytest.param({"currency_display": "iso"}, r"(?i)currency|invalid", id="currency"),
+ pytest.param(
+ {"decimal_precision": -1},
+ r"(?i)decimal|precision|range|invalid",
+ id="precision-low",
+ ),
+ pytest.param(
+ {"decimal_precision": 5},
+ r"(?i)decimal|precision|range|invalid",
+ id="precision-high",
+ ),
+ pytest.param(
+ {"text_color_rgb": [-1, 0, 0]}, r"(?i)color|range|invalid", id="rgb-low"
+ ),
+ pytest.param(
+ {"text_color_rgb": [0, 256, 0]}, r"(?i)color|range|invalid", id="rgb-high"
+ ),
+ pytest.param(
+ {"text_color_cmyk": [-1, 0, 0, 0]}, r"(?i)color|range|invalid", id="cmyk-low"
+ ),
+ pytest.param(
+ {"text_color_cmyk": [0, 101, 0, 0]}, r"(?i)color|range|invalid", id="cmyk-high"
+ ),
+]
+
+
+@pytest.fixture(scope="module")
+def uploaded_zugferd_resources(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+) -> ZugferdResources:
+ paths = [
+ get_test_resource_path("zugferd-minimum-invoice.xml"),
+ get_test_resource_path("signing_logo.png"),
+ get_test_resource_path("report.pdf"),
+ ]
+ with PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ invoice, logo, mismatched_pdf = client.files.create_from_paths(paths)
+ matching_pdf = client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ output="matching-zugferd-source",
+ ).output_file
+ return ZugferdResources(
+ invoice=invoice,
+ logo=logo,
+ mismatched_pdf=mismatched_pdf,
+ matching_pdf=matching_pdf,
+ )
+
+
+def _assert_zugferd_output(
+ output: PdfRestFile,
+ output_bytes: bytes,
+ output_prefix: str,
+) -> None:
+ assert output.name.startswith(output_prefix)
+ assert output.name.endswith(".pdf")
+ assert output.type == "application/pdf"
+ assert output.size == len(output_bytes)
+ assert output.size > 0
+ assert f"/resource/{output.id}" in str(output.url)
+ assert output_bytes.startswith(b"%PDF-")
+
+
+def _render_first_page(
+ client: PdfRestClient, source: PdfRestFile, output: str
+) -> bytes:
+ response = client.convert_to_png(
+ source,
+ page_range="1",
+ output_prefix=output,
+ )
+ assert len(response.output_files) == 1
+ return client.files.read_bytes(response.output_files[0])
+
+
+async def _render_first_page_async(
+ client: AsyncPdfRestClient,
+ source: PdfRestFile,
+ output: str,
+) -> bytes:
+ response = await client.convert_to_png(
+ source,
+ page_range="1",
+ output_prefix=output,
+ )
+ assert len(response.output_files) == 1
+ return await client.files.read_bytes(response.output_files[0])
+
+
+@pytest.mark.parametrize(("render_options", "output"), ISOLATED_RENDER_OPTION_CASES)
+def test_live_convert_invoice_xml_to_zugferd_pdf_isolated_render_option(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+ render_options: PdfZugferdRenderOptions,
+ output: str,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ logo = uploaded_zugferd_resources["logo"]
+ with PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ response = client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ logo=logo,
+ render_options=render_options,
+ output=output,
+ )
+ output_bytes = client.files.read_bytes(response.output_file)
+
+ assert response.input_ids == [invoice.id, logo.id]
+ assert response.warning is None
+ _assert_zugferd_output(response.output_file, output_bytes, output)
+
+
+@pytest.mark.parametrize(("render_options", "output"), ISOLATED_RENDER_OPTION_CASES)
+@pytest.mark.asyncio
+async def test_live_async_convert_invoice_xml_to_zugferd_pdf_isolated_render_option(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+ render_options: PdfZugferdRenderOptions,
+ output: str,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ logo = uploaded_zugferd_resources["logo"]
+ async with AsyncPdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ response = await client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ logo=logo,
+ render_options=render_options,
+ output=f"async-{output}",
+ )
+ output_bytes = await client.files.read_bytes(response.output_file)
+
+ assert response.input_ids == [invoice.id, logo.id]
+ assert response.warning is None
+ _assert_zugferd_output(response.output_file, output_bytes, f"async-{output}")
+
+
+def test_live_convert_invoice_xml_to_zugferd_pdf_observable_render_controls(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ footer = "Observable review footer"
+ with PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ baseline = client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ output="baseline-render-controls",
+ )
+ response = client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ render_options=PdfZugferdRenderOptions(
+ font="courier",
+ bold_font="courierbold",
+ border_color=(0, 100, 100, 0),
+ table_alternate_color=(255, 255, 0),
+ footer_message=footer,
+ ),
+ output="observable-render-controls",
+ )
+ output_bytes = client.files.read_bytes(response.output_file)
+ extracted = client.extract_pdf_text(response.output_file)
+ baseline_png = _render_first_page(
+ client,
+ baseline.output_file,
+ "baseline-render-controls",
+ )
+ customized_png = _render_first_page(
+ client,
+ response.output_file,
+ "observable-render-controls",
+ )
+
+ assert customized_png != baseline_png
+ assert extracted.full_text is not None
+ assert footer in (extracted.full_text.document_text or "")
+ assert response.warning is None
+ _assert_zugferd_output(
+ response.output_file,
+ output_bytes,
+ "observable-render-controls",
+ )
+
+
+@pytest.mark.asyncio
+async def test_live_async_convert_invoice_xml_to_zugferd_pdf_observable_render_controls(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ footer = "Observable async review footer"
+ async with AsyncPdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ baseline = await client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ output="async-baseline-render-controls",
+ )
+ response = await client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ render_options=PdfZugferdRenderOptions(
+ font="courier",
+ bold_font="courierbold",
+ border_color=(0, 100, 100, 0),
+ table_alternate_color=(255, 255, 0),
+ footer_message=footer,
+ ),
+ output="async-observable-render-controls",
+ )
+ output_bytes = await client.files.read_bytes(response.output_file)
+ extracted = await client.extract_pdf_text(response.output_file)
+ baseline_png = await _render_first_page_async(
+ client,
+ baseline.output_file,
+ "async-baseline-render-controls",
+ )
+ customized_png = await _render_first_page_async(
+ client,
+ response.output_file,
+ "async-observable-render-controls",
+ )
+
+ assert customized_png != baseline_png
+ assert extracted.full_text is not None
+ assert footer in (extracted.full_text.document_text or "")
+ assert response.warning is None
+ _assert_zugferd_output(
+ response.output_file,
+ output_bytes,
+ "async-observable-render-controls",
+ )
+
+
+def test_live_convert_pdf_to_zugferd_pdf_preserves_matching_pdf(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ matching_pdf = uploaded_zugferd_resources["matching_pdf"]
+ with PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ source_png = _render_first_page(client, matching_pdf, "source-zugferd")
+ response = client.convert_pdf_to_zugferd_pdf(
+ matching_pdf,
+ invoice=invoice,
+ output="preserved-zugferd",
+ )
+ output_bytes = client.files.read_bytes(response.output_file)
+ output_png = _render_first_page(
+ client,
+ response.output_file,
+ "preserved-zugferd",
+ )
+
+ assert response.input_ids == [invoice.id, matching_pdf.id]
+ assert response.warning is None
+ assert output_png == source_png
+ _assert_zugferd_output(response.output_file, output_bytes, "preserved-zugferd")
+
+
+@pytest.mark.asyncio
+async def test_live_async_convert_pdf_to_zugferd_pdf_preserves_matching_pdf(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ matching_pdf = uploaded_zugferd_resources["matching_pdf"]
+ async with AsyncPdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ source_png = await _render_first_page_async(
+ client,
+ matching_pdf,
+ "async-source-zugferd",
+ )
+ response = await client.convert_pdf_to_zugferd_pdf(
+ matching_pdf,
+ invoice=invoice,
+ output="async-preserved-zugferd",
+ )
+ output_bytes = await client.files.read_bytes(response.output_file)
+ output_png = await _render_first_page_async(
+ client,
+ response.output_file,
+ "async-preserved-zugferd",
+ )
+
+ assert response.input_ids == [invoice.id, matching_pdf.id]
+ assert response.warning is None
+ assert output_png == source_png
+ _assert_zugferd_output(
+ response.output_file,
+ output_bytes,
+ "async-preserved-zugferd",
+ )
+
+
+def test_live_convert_pdf_to_zugferd_pdf_rejects_mismatch_without_permission(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ with (
+ PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client,
+ pytest.raises(
+ PdfRestApiError,
+ match=r"(?i)visual.consistency|comparison",
+ ),
+ ):
+ client.convert_pdf_to_zugferd_pdf(
+ uploaded_zugferd_resources["mismatched_pdf"],
+ invoice=uploaded_zugferd_resources["invoice"],
+ )
+
+
+@pytest.mark.asyncio
+async def test_live_async_convert_pdf_to_zugferd_pdf_rejects_mismatch_without_permission(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ async with AsyncPdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ with pytest.raises(
+ PdfRestApiError,
+ match=r"(?i)visual.consistency|comparison",
+ ):
+ await client.convert_pdf_to_zugferd_pdf(
+ uploaded_zugferd_resources["mismatched_pdf"],
+ invoice=uploaded_zugferd_resources["invoice"],
+ )
+
+
+def test_live_convert_pdf_to_zugferd_pdf_allows_replacement(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ mismatched_pdf = uploaded_zugferd_resources["mismatched_pdf"]
+ logo = uploaded_zugferd_resources["logo"]
+ with PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ source_bytes = client.files.read_bytes(mismatched_pdf)
+ response = client.convert_pdf_to_zugferd_pdf(
+ mismatched_pdf,
+ invoice=invoice,
+ allow_regeneration=True,
+ logo=logo,
+ render_options={"footer_message": "Replacement permitted"},
+ output="replacement-permitted",
+ )
+ output_bytes = client.files.read_bytes(response.output_file)
+
+ assert response.input_ids == [invoice.id, mismatched_pdf.id, logo.id]
+ assert response.warning is None
+ assert output_bytes != source_bytes
+ _assert_zugferd_output(response.output_file, output_bytes, "replacement-permitted")
+
+
+@pytest.mark.asyncio
+async def test_live_async_convert_pdf_to_zugferd_pdf_allows_replacement(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ mismatched_pdf = uploaded_zugferd_resources["mismatched_pdf"]
+ logo = uploaded_zugferd_resources["logo"]
+ async with AsyncPdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ source_bytes = await client.files.read_bytes(mismatched_pdf)
+ response = await client.convert_pdf_to_zugferd_pdf(
+ mismatched_pdf,
+ invoice=invoice,
+ allow_regeneration=True,
+ logo=logo,
+ render_options={"footer_message": "Async replacement permitted"},
+ output="async-replacement-permitted",
+ )
+ output_bytes = await client.files.read_bytes(response.output_file)
+
+ assert response.input_ids == [invoice.id, mismatched_pdf.id, logo.id]
+ assert response.warning is None
+ assert output_bytes != source_bytes
+ _assert_zugferd_output(
+ response.output_file,
+ output_bytes,
+ "async-replacement-permitted",
+ )
+
+
+@pytest.mark.parametrize(
+ ("invalid_options", "error_match"), INVALID_RENDER_OPTION_CASES
+)
+def test_live_convert_invoice_xml_to_zugferd_pdf_rejects_invalid_render_options(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+ invalid_options: dict[str, object],
+ error_match: str,
+) -> None:
+ with (
+ PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client,
+ pytest.raises(PdfRestApiError, match=error_match),
+ ):
+ client.convert_invoice_xml_to_zugferd_pdf(
+ uploaded_zugferd_resources["invoice"],
+ extra_body={"render_options": invalid_options},
+ )
+
+
+@pytest.mark.parametrize(
+ ("invalid_options", "error_match"), INVALID_RENDER_OPTION_CASES
+)
+@pytest.mark.asyncio
+async def test_live_async_convert_invoice_xml_to_zugferd_pdf_rejects_invalid_render_options(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+ invalid_options: dict[str, object],
+ error_match: str,
+) -> None:
+ async with AsyncPdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ with pytest.raises(PdfRestApiError, match=error_match):
+ await client.convert_invoice_xml_to_zugferd_pdf(
+ uploaded_zugferd_resources["invoice"],
+ extra_body={"render_options": invalid_options},
+ )
diff --git a/tests/resources/zugferd-minimum-invoice.xml b/tests/resources/zugferd-minimum-invoice.xml
new file mode 100644
index 0000000..9fd3a95
--- /dev/null
+++ b/tests/resources/zugferd-minimum-invoice.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+ urn:factur-x.eu:1p0:minimum
+
+
+
+ 471102
+ 380
+
+ 20200305
+
+
+
+
+
+ Lieferant GmbH
+
+ DE
+
+
+ 201/113/40209
+
+
+ DE123456789
+
+
+
+ Kunden AG Frankreich
+
+
+
+
+ EUR
+
+ 198.00
+ 37.62
+ 235.62
+ 235.62
+
+
+
+
diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py
index b5e4c72..229275f 100644
--- a/tests/test_convert_structured_documents_to_pdf.py
+++ b/tests/test_convert_structured_documents_to_pdf.py
@@ -18,6 +18,7 @@
ConvertXmlToPdfPayload,
)
from pdfrest.types import (
+ PdfKnownTextFont,
PdfStructuredTextFontName,
PdfStructuredTextKnownFont,
PdfStructuredTextPageSetup,
@@ -44,7 +45,7 @@
]
KNOWN_FONTS = [
pytest.param(value, id=value.replace(" ", "-"))
- for value in get_args(PdfStructuredTextKnownFont)
+ for value in (*get_args(PdfKnownTextFont), "webdings")
]
CSV_DELIMITERS = [
diff --git a/tests/test_create_zugferd_pdf.py b/tests/test_create_zugferd_pdf.py
new file mode 100644
index 0000000..9384bee
--- /dev/null
+++ b/tests/test_create_zugferd_pdf.py
@@ -0,0 +1,989 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from typing import Any, cast, get_args
+
+import httpx
+import pytest
+from pydantic import BaseModel, ValidationError
+
+from pdfrest import AsyncPdfRestClient, PdfRestClient
+from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID
+from pdfrest.models._internal import (
+ PdfInvoiceXmlToZugferdPayload,
+ PdfToZugferdPayload,
+)
+from pdfrest.types import (
+ PdfZugferdCurrencyDisplay,
+ PdfZugferdKnownFont,
+ PdfZugferdLocale,
+ PdfZugferdLogoCorner,
+ PdfZugferdRenderOptions,
+)
+
+from .graphics_test_helpers import (
+ ASYNC_API_KEY,
+ VALID_API_KEY,
+ build_file_info_payload,
+)
+
+KNOWN_FONTS = cast(tuple[PdfZugferdKnownFont, ...], get_args(PdfZugferdKnownFont))
+LOGO_CORNERS = cast(tuple[PdfZugferdLogoCorner, ...], get_args(PdfZugferdLogoCorner))
+LOCALES = cast(tuple[PdfZugferdLocale, ...], get_args(PdfZugferdLocale))
+CURRENCY_DISPLAYS = cast(
+ tuple[PdfZugferdCurrencyDisplay, ...], get_args(PdfZugferdCurrencyDisplay)
+)
+
+
+def _make_file(name: str, mime_type: str, *, prefix: int | None = None) -> PdfRestFile:
+ file_id = PdfRestFileID.generate(prefix)
+ return PdfRestFile.model_validate(
+ {
+ "id": file_id,
+ "name": name,
+ "url": f"https://api.pdfrest.com/resource/{file_id}",
+ "type": mime_type,
+ "size": 1024,
+ "modified": datetime(2024, 1, 1, tzinfo=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z"),
+ "scheduledDeletionTimeUtc": None,
+ }
+ )
+
+
+def _dump(model: type[BaseModel], payload: dict[str, Any]) -> dict[str, Any]:
+ return model.model_validate(payload).model_dump(
+ mode="json", by_alias=True, exclude_none=True, exclude_unset=True
+ )
+
+
+def _assert_timeout(value: float | dict[str, float] | None, expected: float) -> None:
+ assert value is not None
+ if isinstance(value, dict):
+ assert value
+ assert all(component == pytest.approx(expected) for component in value.values())
+ else:
+ assert value == pytest.approx(expected)
+
+
+def _assert_response(
+ response: PdfRestFileBasedResponse,
+ *,
+ input_ids: list[PdfRestFileID],
+ output_id: PdfRestFileID,
+ output_name: str,
+) -> None:
+ assert response.input_ids == input_ids
+ assert response.warning is None
+ assert len(response.output_files) == 1
+ assert response.output_file.id == output_id
+ assert response.output_file.name == output_name
+ assert response.output_file.type == "application/pdf"
+ assert response.output_file.size == 256
+ assert str(response.output_file.url).endswith(f"/resource/{output_id}")
+
+
+def test_invoice_xml_payload_serializes_all_render_options() -> None:
+ invoice = _make_file("invoice.xml", "application/xml", prefix=1)
+ logo = _make_file("logo.png", "image/png", prefix=2)
+
+ payload = _dump(
+ PdfInvoiceXmlToZugferdPayload,
+ {
+ "invoice": invoice,
+ "logo": logo,
+ "output": "customer-invoice",
+ "render_options": {
+ "logo_corner": "top-right",
+ "font": " arial ",
+ "bold_font": "arialbold",
+ "text_color": (26, 31, 41),
+ "muted_text_color": (10, 5, 0, 55),
+ "accent_color": (20, 64, 115),
+ "border_color": (12, 7, 0, 20),
+ "table_header_color": (20, 64, 115),
+ "table_alternate_color": (4, 2, 0, 2),
+ "footer_message": "Thank you for your business.",
+ "locale": "de-DE",
+ "label_language": "eng-US",
+ "currency_display": "symbol",
+ "date_format": "dd.MM.yyyy",
+ "decimal_precision": 3,
+ },
+ },
+ )
+
+ assert payload == {
+ "id": str(invoice.id),
+ "logo_id": str(logo.id),
+ "render_options": {
+ "logo_corner": "top-right",
+ "font": "arial",
+ "bold_font": "arialbold",
+ "text_color_rgb": [26, 31, 41],
+ "muted_text_color_cmyk": [10, 5, 0, 55],
+ "accent_color_rgb": [20, 64, 115],
+ "border_color_cmyk": [12, 7, 0, 20],
+ "table_header_color_rgb": [20, 64, 115],
+ "table_alternate_color_cmyk": [4, 2, 0, 2],
+ "footer_message": "Thank you for your business.",
+ "locale": "de-DE",
+ "label_language": "en",
+ "currency_display": "symbol",
+ "date_format": "dd.MM.yyyy",
+ "decimal_precision": 3,
+ },
+ "output": "customer-invoice",
+ }
+
+
+def test_pdf_payload_maps_regeneration_permission_to_wire_field() -> None:
+ invoice = _make_file("invoice.xml", "text/xml", prefix=1)
+ pdf = _make_file("invoice.pdf", "application/pdf", prefix=2)
+
+ payload = _dump(
+ PdfToZugferdPayload,
+ {
+ "invoice": invoice,
+ "pdf": pdf,
+ "regenerate_pdf": True,
+ "render_options": {"label_language": "fra-FR"},
+ },
+ )
+
+ assert payload == {
+ "id": str(invoice.id),
+ "pdf_id": str(pdf.id),
+ "render_options": {"label_language": "fr"},
+ "regenerate_pdf": True,
+ }
+
+
+@pytest.mark.parametrize(
+ "field",
+ [
+ pytest.param("text_color", id="text"),
+ pytest.param("muted_text_color", id="muted-text"),
+ pytest.param("accent_color", id="accent"),
+ pytest.param("border_color", id="border"),
+ pytest.param("table_header_color", id="table-header"),
+ pytest.param("table_alternate_color", id="table-alternate"),
+ ],
+)
+@pytest.mark.parametrize(
+ ("color", "wire_suffix"),
+ [
+ pytest.param((0, 127, 255), "rgb", id="rgb"),
+ pytest.param((0, 25, 50, 100), "cmyk", id="cmyk"),
+ ],
+)
+def test_zugferd_payload_routes_unified_colors(
+ field: str,
+ color: tuple[int, ...],
+ wire_suffix: str,
+) -> None:
+ invoice = _make_file("invoice.xml", "text/xml")
+ payload = _dump(
+ PdfInvoiceXmlToZugferdPayload,
+ {"invoice": invoice, "render_options": {field: color}},
+ )
+ assert payload["render_options"] == {f"{field}_{wire_suffix}": list(color)}
+
+
+@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str)
+def test_zugferd_payload_accepts_every_published_font(
+ font: PdfZugferdKnownFont,
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+ payload = _dump(
+ PdfInvoiceXmlToZugferdPayload,
+ {"invoice": invoice, "render_options": {"font": font}},
+ )
+ assert payload["render_options"] == {"font": font}
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ pytest.param("en", "en", id="english"),
+ pytest.param("eng", "en", id="english-alias"),
+ pytest.param("eng-US", "en", id="english-region"),
+ pytest.param("de", "de", id="german"),
+ pytest.param("deu", "de", id="german-alias"),
+ pytest.param("fr", "fr", id="french"),
+ pytest.param("fra-FR", "fr", id="french-region"),
+ ],
+)
+def test_zugferd_payload_canonicalizes_supported_label_languages(
+ value: str, expected: str
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+ payload = _dump(
+ PdfInvoiceXmlToZugferdPayload,
+ {"invoice": invoice, "render_options": {"label_language": value}},
+ )
+ assert payload["render_options"] == {"label_language": expected}
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ *(
+ pytest.param("logo_corner", value, id=f"logo-{value}")
+ for value in LOGO_CORNERS
+ ),
+ *(pytest.param("locale", value, id=f"locale-{value}") for value in LOCALES),
+ *(
+ pytest.param("currency_display", value, id=f"currency-{value}")
+ for value in CURRENCY_DISPLAYS
+ ),
+ ],
+)
+def test_zugferd_payload_accepts_every_closed_literal(field: str, value: str) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+ payload = _dump(
+ PdfInvoiceXmlToZugferdPayload,
+ {"invoice": invoice, "render_options": {field: value}},
+ )
+ assert payload["render_options"] == {field: value}
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ pytest.param({"decimal_precision": 0}, id="precision-minimum"),
+ pytest.param({"decimal_precision": 4}, id="precision-maximum"),
+ pytest.param({"text_color": (0, 0, 0)}, id="rgb-minimum"),
+ pytest.param({"text_color": (255, 255, 255)}, id="rgb-maximum"),
+ pytest.param({"text_color": (0, 0, 0, 0)}, id="cmyk-minimum"),
+ pytest.param({"text_color": (100, 100, 100, 100)}, id="cmyk-maximum"),
+ pytest.param({"font": "x"}, id="font-minimum"),
+ pytest.param({"font": "x" * 200}, id="font-maximum"),
+ pytest.param({"footer_message": ""}, id="footer-empty"),
+ pytest.param({"footer_message": "x" * 500}, id="footer-maximum"),
+ ],
+)
+def test_zugferd_payload_accepts_constraint_boundaries(options: dict[str, Any]) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+ _dump(
+ PdfInvoiceXmlToZugferdPayload,
+ {"invoice": invoice, "render_options": options},
+ )
+
+
+@pytest.mark.parametrize(
+ ("options", "error_match"),
+ [
+ pytest.param(
+ {"decimal_precision": -1}, "greater than or equal to 0", id="precision-low"
+ ),
+ pytest.param(
+ {"decimal_precision": 5}, "less than or equal to 4", id="precision-high"
+ ),
+ pytest.param(
+ {"text_color": (-1, 0, 0)}, "greater than or equal to 0", id="rgb-low"
+ ),
+ pytest.param(
+ {"text_color": (0, 256, 0)}, "less than or equal to 255", id="rgb-high"
+ ),
+ pytest.param(
+ {"text_color": (-1, 0, 0, 0)}, "greater than or equal to 0", id="cmyk-low"
+ ),
+ pytest.param(
+ {"text_color": (0, 101, 0, 0)}, "less than or equal to 100", id="cmyk-high"
+ ),
+ pytest.param({"text_color": (0, 0)}, "exactly 3.*or 4", id="channel-count"),
+ pytest.param({"font": " "}, "at least 1 character", id="font-empty"),
+ pytest.param({"font": "x" * 201}, "at most 200 characters", id="font-long"),
+ pytest.param({"font": "Webdings"}, "Webdings is unsupported", id="font-symbol"),
+ pytest.param(
+ {"footer_message": "x" * 501}, "at most 500 characters", id="footer-long"
+ ),
+ pytest.param(
+ {"label_language": "nl"},
+ "English, German, or French",
+ id="language-primary",
+ ),
+ pytest.param(
+ {"label_language": "en_"},
+ "String should match pattern",
+ id="language-shape",
+ ),
+ pytest.param({"label_language": None}, "valid string", id="language-null"),
+ pytest.param({"logo_corner": "center"}, "logo_corner", id="logo-corner"),
+ pytest.param({"locale": "en-US"}, "locale", id="locale"),
+ pytest.param({"currency_display": "iso"}, "currency_display", id="currency"),
+ ],
+)
+def test_zugferd_payload_rejects_invalid_render_options(
+ options: dict[str, Any], error_match: str
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+ with pytest.raises(ValidationError, match=error_match):
+ PdfInvoiceXmlToZugferdPayload.model_validate(
+ {"invoice": invoice, "render_options": options}
+ )
+
+
+@pytest.mark.parametrize(
+ ("payload", "error_match"),
+ [
+ pytest.param(
+ {"invoice": _make_file("invoice.pdf", "application/pdf")},
+ "Invoice must be an XML file",
+ id="invoice-mime",
+ ),
+ pytest.param({"invoice": []}, "at least 1 item", id="invoice-empty"),
+ pytest.param(
+ {
+ "invoice": [
+ _make_file("one.xml", "application/xml"),
+ _make_file("two.xml", "text/xml"),
+ ]
+ },
+ "at most 1 item",
+ id="invoice-extra",
+ ),
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "logo": _make_file("logo.gif", "image/gif"),
+ },
+ "Logo must be a PNG or JPEG file",
+ id="logo-mime",
+ ),
+ pytest.param(
+ {"invoice": _make_file("invoice.xml", "application/xml"), "logo": []},
+ "at least 1 item",
+ id="logo-empty",
+ ),
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "logo": [
+ _make_file("one.png", "image/png"),
+ _make_file("two.jpg", "image/jpeg"),
+ ],
+ },
+ "at most 1 item",
+ id="logo-extra",
+ ),
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "output": "../unsafe",
+ },
+ "directory separator",
+ id="unsafe-output",
+ ),
+ ],
+)
+def test_invoice_xml_payload_rejects_invalid_resources(
+ payload: dict[str, Any], error_match: str
+) -> None:
+ with pytest.raises(ValidationError, match=error_match):
+ PdfInvoiceXmlToZugferdPayload.model_validate(payload)
+
+
+@pytest.mark.parametrize(
+ ("payload", "error_match"),
+ [
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "pdf": _make_file("source.png", "image/png"),
+ },
+ "Must be a PDF file",
+ id="pdf-mime",
+ ),
+ pytest.param(
+ {"invoice": _make_file("invoice.xml", "application/xml"), "pdf": []},
+ "at least 1 item",
+ id="pdf-empty",
+ ),
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "pdf": [
+ _make_file("one.pdf", "application/pdf"),
+ _make_file("two.pdf", "application/pdf"),
+ ],
+ },
+ "at most 1 item",
+ id="pdf-extra",
+ ),
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "pdf": _make_file("source.pdf", "application/pdf"),
+ "logo": _make_file("logo.png", "image/png"),
+ },
+ "allow_regeneration=True",
+ id="logo-without-permission",
+ ),
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "pdf": _make_file("source.pdf", "application/pdf"),
+ "render_options": {"font": "arial"},
+ },
+ "allow_regeneration=True",
+ id="render-without-permission",
+ ),
+ ],
+)
+def test_pdf_payload_rejects_invalid_resources(
+ payload: dict[str, Any], error_match: str
+) -> None:
+ with pytest.raises(ValidationError, match=error_match):
+ PdfToZugferdPayload.model_validate(payload)
+
+
+@pytest.mark.parametrize("invoice_mime", ["application/xml", "text/xml"])
+@pytest.mark.parametrize("logo_mime", ["image/png", "image/jpeg"])
+def test_convert_invoice_xml_to_zugferd_pdf_supported_mime_matrix(
+ invoice_mime: str, logo_mime: str
+) -> None:
+ invoice = _make_file("invoice.xml", invoice_mime, prefix=1)
+ logo = _make_file("logo", logo_mime, prefix=2)
+ output_id = PdfRestFileID.generate()
+ counts = {"post": 0, "get": 0}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "POST":
+ counts["post"] += 1
+ assert json.loads(request.content) == {
+ "id": str(invoice.id),
+ "logo_id": str(logo.id),
+ "render_options": {"font": "arial", "label_language": "en"},
+ "output": "zugferd-result",
+ }
+ return httpx.Response(
+ 200,
+ json={
+ "inputId": [str(invoice.id), str(logo.id)],
+ "outputId": str(output_id),
+ },
+ )
+ counts["get"] += 1
+ return httpx.Response(
+ 200,
+ json=build_file_info_payload(
+ str(output_id), "zugferd-result.pdf", "application/pdf"
+ ),
+ )
+
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = client.convert_invoice_xml_to_zugferd_pdf(
+ invoice,
+ logo=logo,
+ render_options={"font": "arial", "label_language": "eng-US"},
+ output="zugferd-result",
+ )
+
+ assert counts == {"post": 1, "get": 1}
+ _assert_response(
+ response,
+ input_ids=[invoice.id, logo.id],
+ output_id=output_id,
+ output_name="zugferd-result.pdf",
+ )
+
+
+@pytest.mark.parametrize("invoice_mime", ["application/xml", "text/xml"])
+@pytest.mark.parametrize("logo_mime", ["image/png", "image/jpeg"])
+@pytest.mark.asyncio
+async def test_async_convert_invoice_xml_to_zugferd_pdf_supported_mime_matrix(
+ invoice_mime: str, logo_mime: str
+) -> None:
+ invoice = _make_file("invoice.xml", invoice_mime, prefix=1)
+ logo = _make_file("logo", logo_mime, prefix=2)
+ output_id = PdfRestFileID.generate()
+ counts = {"post": 0, "get": 0}
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "POST":
+ counts["post"] += 1
+ assert json.loads(request.content)["logo_id"] == str(logo.id)
+ return httpx.Response(
+ 200,
+ json={
+ "inputId": [str(invoice.id), str(logo.id)],
+ "outputId": str(output_id),
+ },
+ )
+ counts["get"] += 1
+ return httpx.Response(
+ 200,
+ json=build_file_info_payload(
+ str(output_id), "zugferd-async.pdf", "application/pdf"
+ ),
+ )
+
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = await client.convert_invoice_xml_to_zugferd_pdf(
+ invoice, logo=logo, output="zugferd-async"
+ )
+
+ assert counts == {"post": 1, "get": 1}
+ _assert_response(
+ response,
+ input_ids=[invoice.id, logo.id],
+ output_id=output_id,
+ output_name="zugferd-async.pdf",
+ )
+
+
+def test_convert_pdf_to_zugferd_pdf_preserves_pdf_by_default() -> None:
+ invoice = _make_file("invoice.xml", "application/xml", prefix=1)
+ pdf = _make_file("invoice.pdf", "application/pdf", prefix=2)
+ output_id = PdfRestFileID.generate()
+ counts = {"post": 0, "get": 0}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "POST":
+ counts["post"] += 1
+ assert json.loads(request.content) == {
+ "id": str(invoice.id),
+ "pdf_id": str(pdf.id),
+ "regenerate_pdf": False,
+ "output": "preserved",
+ }
+ return httpx.Response(
+ 200,
+ json={
+ "inputId": [str(invoice.id), str(pdf.id)],
+ "outputId": str(output_id),
+ },
+ )
+ counts["get"] += 1
+ return httpx.Response(
+ 200,
+ json=build_file_info_payload(
+ str(output_id), "preserved.pdf", "application/pdf"
+ ),
+ )
+
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = client.convert_pdf_to_zugferd_pdf(
+ pdf, invoice=invoice, output="preserved"
+ )
+
+ assert counts == {"post": 1, "get": 1}
+ _assert_response(
+ response,
+ input_ids=[invoice.id, pdf.id],
+ output_id=output_id,
+ output_name="preserved.pdf",
+ )
+
+
+@pytest.mark.asyncio
+async def test_async_convert_pdf_to_zugferd_pdf_allows_regeneration_options() -> None:
+ invoice = _make_file("invoice.xml", "text/xml", prefix=1)
+ pdf = _make_file("invoice.pdf", "application/pdf", prefix=2)
+ logo = _make_file("logo.jpg", "image/jpeg")
+ output_id = PdfRestFileID.generate()
+ counts = {"post": 0, "get": 0}
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "POST":
+ counts["post"] += 1
+ assert json.loads(request.content) == {
+ "id": str(invoice.id),
+ "pdf_id": str(pdf.id),
+ "logo_id": str(logo.id),
+ "render_options": {
+ "bold_font": "arialbold",
+ "border_color_cmyk": [0, 0, 0, 50],
+ },
+ "regenerate_pdf": True,
+ "output": "replacement-allowed",
+ }
+ return httpx.Response(
+ 200,
+ json={
+ "inputId": [str(invoice.id), str(pdf.id), str(logo.id)],
+ "outputId": str(output_id),
+ },
+ )
+ counts["get"] += 1
+ return httpx.Response(
+ 200,
+ json=build_file_info_payload(
+ str(output_id), "replacement-allowed.pdf", "application/pdf"
+ ),
+ )
+
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = await client.convert_pdf_to_zugferd_pdf(
+ pdf,
+ invoice=invoice,
+ allow_regeneration=True,
+ logo=logo,
+ render_options={"bold_font": "arialbold", "border_color": (0, 0, 0, 50)},
+ output="replacement-allowed",
+ )
+
+ assert counts == {"post": 1, "get": 1}
+ _assert_response(
+ response,
+ input_ids=[invoice.id, pdf.id, logo.id],
+ output_id=output_id,
+ output_name="replacement-allowed.pdf",
+ )
+
+
+@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str)
+def test_convert_invoice_xml_to_zugferd_pdf_accepts_every_published_font(
+ font: PdfZugferdKnownFont,
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert json.loads(request.content)["render_options"] == {"font": font}
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ client.convert_invoice_xml_to_zugferd_pdf(
+ invoice, render_options={"font": font}
+ )
+
+
+@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str)
+@pytest.mark.asyncio
+async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_every_published_font(
+ font: PdfZugferdKnownFont,
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ assert json.loads(request.content)["render_options"] == {"font": font}
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ await client.convert_invoice_xml_to_zugferd_pdf(
+ invoice, render_options={"font": font}
+ )
+
+
+CLIENT_RENDER_CASES = [
+ *(
+ pytest.param("logo_corner", value, value, id=f"logo-{value}")
+ for value in LOGO_CORNERS
+ ),
+ *(pytest.param("locale", value, value, id=f"locale-{value}") for value in LOCALES),
+ *(
+ pytest.param("currency_display", value, value, id=f"currency-{value}")
+ for value in CURRENCY_DISPLAYS
+ ),
+ pytest.param("label_language", "eng", "en", id="language-english-alias"),
+ pytest.param("label_language", "deu-DE", "de", id="language-german-alias"),
+ pytest.param("label_language", "fra", "fr", id="language-french-alias"),
+]
+
+
+@pytest.mark.parametrize(("field", "value", "expected"), CLIENT_RENDER_CASES)
+def test_convert_invoice_xml_to_zugferd_pdf_accepts_render_contract(
+ field: str, value: str, expected: str
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert json.loads(request.content)["render_options"] == {field: expected}
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ options = cast(PdfZugferdRenderOptions, cast(object, {field: value}))
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options)
+
+
+@pytest.mark.parametrize(("field", "value", "expected"), CLIENT_RENDER_CASES)
+@pytest.mark.asyncio
+async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_render_contract(
+ field: str, value: str, expected: str
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ assert json.loads(request.content)["render_options"] == {field: expected}
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ options = cast(PdfZugferdRenderOptions, cast(object, {field: value}))
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ await client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options)
+
+
+INVALID_GENERATION_CASES = [
+ pytest.param("invoice-mime", "Invoice must be an XML file", id="invoice-mime"),
+ pytest.param("invoice-empty", "at least 1 item", id="invoice-empty"),
+ pytest.param("invoice-extra", "at most 1 item", id="invoice-extra"),
+ pytest.param("logo-mime", "Logo must be a PNG or JPEG file", id="logo-mime"),
+ pytest.param("logo-empty", "at least 1 item", id="logo-empty"),
+ pytest.param("logo-extra", "at most 1 item", id="logo-extra"),
+ pytest.param("invalid-literal", "locale", id="invalid-literal"),
+ pytest.param("malformed-color", "greater than or equal to 0", id="malformed-color"),
+ pytest.param("unsafe-output", "directory separator", id="unsafe-output"),
+ pytest.param("language-null", "valid string", id="language-null"),
+ pytest.param("font-empty", "at least 1 character", id="font-empty"),
+ pytest.param("font-long", "at most 200 characters", id="font-long"),
+ pytest.param("footer-long", "at most 500 characters", id="footer-long"),
+ pytest.param("precision-high", "less than or equal to 4", id="precision-high"),
+]
+
+
+def _invoke_invalid_generation(client: Any, case: str) -> Any:
+ invoice = _make_file("invoice.xml", "application/xml")
+ cases: dict[str, tuple[Any, dict[str, Any]]] = {
+ "invoice-mime": (_make_file("invoice.pdf", "application/pdf"), {}),
+ "invoice-empty": ([], {}),
+ "invoice-extra": (
+ [invoice, _make_file("second.xml", "text/xml")],
+ {},
+ ),
+ "logo-mime": (invoice, {"logo": _make_file("logo.gif", "image/gif")}),
+ "logo-empty": (invoice, {"logo": []}),
+ "logo-extra": (
+ invoice,
+ {
+ "logo": [
+ _make_file("one.png", "image/png"),
+ _make_file("two.jpg", "image/jpeg"),
+ ]
+ },
+ ),
+ "invalid-literal": (invoice, {"render_options": {"locale": "en-US"}}),
+ "malformed-color": (
+ invoice,
+ {"render_options": {"text_color": (-1, 0, 0)}},
+ ),
+ "unsafe-output": (invoice, {"output": "../unsafe"}),
+ "language-null": (
+ invoice,
+ {"render_options": {"label_language": None}},
+ ),
+ "font-empty": (invoice, {"render_options": {"font": " "}}),
+ "font-long": (invoice, {"render_options": {"font": "x" * 201}}),
+ "footer-long": (
+ invoice,
+ {"render_options": {"footer_message": "x" * 501}},
+ ),
+ "precision-high": (
+ invoice,
+ {"render_options": {"decimal_precision": 5}},
+ ),
+ }
+ invoice, kwargs = cases[case]
+ return client.convert_invoice_xml_to_zugferd_pdf(invoice, **kwargs)
+
+
+@pytest.mark.parametrize(("case", "error_match"), INVALID_GENERATION_CASES)
+def test_convert_invoice_xml_to_zugferd_pdf_rejects_invalid_input_before_transport(
+ case: str, error_match: str
+) -> None:
+ def handler(_: httpx.Request) -> httpx.Response:
+ pytest.fail("transport must not run for invalid local input")
+
+ with (
+ PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client,
+ pytest.raises(ValidationError, match=error_match),
+ ):
+ _invoke_invalid_generation(client, case)
+
+
+@pytest.mark.parametrize(("case", "error_match"), INVALID_GENERATION_CASES)
+@pytest.mark.asyncio
+async def test_async_convert_invoice_xml_to_zugferd_pdf_rejects_invalid_input_before_transport(
+ case: str, error_match: str
+) -> None:
+ async def handler(_: httpx.Request) -> httpx.Response:
+ pytest.fail("transport must not run for invalid local input")
+
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ with pytest.raises(ValidationError, match=error_match):
+ await _invoke_invalid_generation(client, case)
+
+
+INVALID_PDF_CASES = [
+ pytest.param("pdf-mime", "Must be a PDF file", id="pdf-mime"),
+ pytest.param("pdf-empty", "at least 1 item", id="pdf-empty"),
+ pytest.param("pdf-extra", "at most 1 item", id="pdf-extra"),
+ pytest.param("invoice-mime", "Invoice must be an XML file", id="invoice-mime"),
+ pytest.param("invoice-empty", "at least 1 item", id="invoice-empty"),
+ pytest.param("invoice-extra", "at most 1 item", id="invoice-extra"),
+ pytest.param("logo-mime", "Logo must be a PNG or JPEG file", id="logo-mime"),
+ pytest.param(
+ "logo-without-permission",
+ "allow_regeneration=True",
+ id="logo-without-permission",
+ ),
+ pytest.param(
+ "render-without-permission",
+ "allow_regeneration=True",
+ id="render-without-permission",
+ ),
+]
+
+
+def _invoke_invalid_pdf_conversion(client: Any, case: str) -> Any:
+ pdf: Any = _make_file("source.pdf", "application/pdf")
+ invoice: Any = _make_file("invoice.xml", "application/xml")
+ kwargs: dict[str, Any] = {}
+ if case == "pdf-mime":
+ pdf = _make_file("source.png", "image/png")
+ elif case == "pdf-empty":
+ pdf = []
+ elif case == "pdf-extra":
+ pdf = [pdf, _make_file("second.pdf", "application/pdf")]
+ elif case == "invoice-mime":
+ invoice = _make_file("invoice.pdf", "application/pdf")
+ elif case == "invoice-empty":
+ invoice = []
+ elif case == "invoice-extra":
+ invoice = [invoice, _make_file("second.xml", "text/xml")]
+ elif case == "logo-mime":
+ kwargs.update(allow_regeneration=True, logo=_make_file("logo.gif", "image/gif"))
+ elif case == "logo-without-permission":
+ kwargs["logo"] = _make_file("logo.png", "image/png")
+ else:
+ kwargs["render_options"] = {"font": "arial"}
+ return client.convert_pdf_to_zugferd_pdf(pdf, invoice=invoice, **kwargs)
+
+
+@pytest.mark.parametrize(("case", "error_match"), INVALID_PDF_CASES)
+def test_convert_pdf_to_zugferd_pdf_rejects_invalid_input_before_transport(
+ case: str, error_match: str
+) -> None:
+ def handler(_: httpx.Request) -> httpx.Response:
+ pytest.fail("transport must not run for invalid local input")
+
+ with (
+ PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client,
+ pytest.raises(ValidationError, match=error_match),
+ ):
+ _invoke_invalid_pdf_conversion(client, case)
+
+
+@pytest.mark.parametrize(("case", "error_match"), INVALID_PDF_CASES)
+@pytest.mark.asyncio
+async def test_async_convert_pdf_to_zugferd_pdf_rejects_invalid_input_before_transport(
+ case: str, error_match: str
+) -> None:
+ async def handler(_: httpx.Request) -> httpx.Response:
+ pytest.fail("transport must not run for invalid local input")
+
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ with pytest.raises(ValidationError, match=error_match):
+ await _invoke_invalid_pdf_conversion(client, case)
+
+
+@pytest.mark.parametrize(
+ ("method_name", "expected_body"),
+ [
+ pytest.param(
+ "convert_invoice_xml_to_zugferd_pdf",
+ {"regenerate_pdf": False},
+ id="invoice-xml",
+ ),
+ pytest.param(
+ "convert_pdf_to_zugferd_pdf",
+ {"regenerate_pdf": False},
+ id="supplied-pdf",
+ ),
+ ],
+)
+def test_zugferd_sync_request_customization(
+ method_name: str, expected_body: dict[str, Any]
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+ pdf = _make_file("source.pdf", "application/pdf")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert request.url.params["trace"] == "sync"
+ assert request.headers["X-Trace"] == "zugferd"
+ _assert_timeout(request.extensions.get("timeout"), 14.0)
+ body = json.loads(request.content)
+ assert body["server_debug"] is True
+ if method_name == "convert_pdf_to_zugferd_pdf":
+ assert body["regenerate_pdf"] is expected_body["regenerate_pdf"]
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ method = getattr(client, method_name)
+ kwargs = {
+ "extra_query": {"trace": "sync"},
+ "extra_headers": {"X-Trace": "zugferd"},
+ "extra_body": {"server_debug": True},
+ "timeout": 14.0,
+ }
+ if method_name == "convert_pdf_to_zugferd_pdf":
+ response = method(pdf, invoice=invoice, **kwargs)
+ else:
+ response = method(invoice, **kwargs)
+ assert response.input_id == invoice.id
+ assert response.output_files == []
+
+
+@pytest.mark.parametrize(
+ "method_name",
+ [
+ pytest.param("convert_invoice_xml_to_zugferd_pdf", id="invoice-xml"),
+ pytest.param("convert_pdf_to_zugferd_pdf", id="supplied-pdf"),
+ ],
+)
+@pytest.mark.asyncio
+async def test_zugferd_async_request_customization(method_name: str) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+ pdf = _make_file("source.pdf", "application/pdf")
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ assert request.url.params["trace"] == "async"
+ assert request.headers["X-Trace"] == "zugferd-async"
+ _assert_timeout(request.extensions.get("timeout"), 21.0)
+ assert json.loads(request.content)["server_debug"] is True
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ method = getattr(client, method_name)
+ kwargs = {
+ "extra_query": {"trace": "async"},
+ "extra_headers": {"X-Trace": "zugferd-async"},
+ "extra_body": {"server_debug": True},
+ "timeout": 21.0,
+ }
+ if method_name == "convert_pdf_to_zugferd_pdf":
+ response = await method(pdf, invoice=invoice, **kwargs)
+ else:
+ response = await method(invoice, **kwargs)
+ assert response.input_id == invoice.id
+ assert response.output_files == []
diff --git a/uv.lock b/uv.lock
index 544df01..e261891 100644
--- a/uv.lock
+++ b/uv.lock
@@ -964,7 +964,7 @@ wheels = [
[[package]]
name = "pdfrest"
-version = "1.4.1"
+version = "1.5.0"
source = { editable = "." }
dependencies = [
{ name = "exceptiongroup" },