diff --git a/docs/api-guide.md b/docs/api-guide.md
index b233d88..be447f2 100644
--- a/docs/api-guide.md
+++ b/docs/api-guide.md
@@ -107,6 +107,8 @@ 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:
+ [create_zugferd_pdf][pdfrest.PdfRestClient.create_zugferd_pdf]
## PDF cleanup and rendering normalization
@@ -130,6 +132,7 @@ Use this group when the user starts with non-PDF content or needs downstream
formats.
- Into PDF:
+ [create_zugferd_pdf][pdfrest.PdfRestClient.create_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..56da7ec 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -158,6 +158,8 @@ 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 optional source-PDF, logo, and render controls
### Extraction and Analysis
diff --git a/examples/README.md b/examples/README.md
index 39bf5ee..5fc6e5a 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -35,6 +35,8 @@ 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/create_zugferd_pdf/create_zugferd_pdf_example.py` – create a
+ formatted ZUGFeRD PDF/A-3 invoice from uploaded CII XML.
- `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/create_zugferd_pdf/create_zugferd_pdf_example.py b/examples/create_zugferd_pdf/create_zugferd_pdf_example.py
new file mode 100644
index 0000000..1e9bb3a
--- /dev/null
+++ b/examples/create_zugferd_pdf/create_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/create_zugferd_pdf/create_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 create_zugferd_pdf() -> None:
+ """Upload invoice resources, 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.create_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
+ create_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/src/pdfrest/client.py b/src/pdfrest/client.py
index 7849669..94878ae 100644
--- a/src/pdfrest/client.py
+++ b/src/pdfrest/client.py
@@ -129,6 +129,7 @@
PdfToWordPayload,
PdfUnrestrictPayload,
PdfXfaToAcroformsPayload,
+ PdfZugferdPayload,
PngPdfRestPayload,
SummarizePdfTextPayload,
TiffPdfRestPayload,
@@ -178,6 +179,7 @@
PdfStructuredTextTableStyle,
PdfTextColor,
PdfXType,
+ PdfZugferdRenderOptions,
PngColorModel,
PostScriptLevel,
SummaryFormat,
@@ -4615,6 +4617,69 @@ def sign_pdf(
timeout=timeout,
)
+ def create_zugferd_pdf(
+ self,
+ invoice: PdfRestFile | Sequence[PdfRestFile],
+ *,
+ pdf: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ render_options: PdfZugferdRenderOptions | None = None,
+ regenerate_pdf: bool = False,
+ output: str | None = None,
+ extra_query: Query | None = None,
+ extra_headers: AnyMapping | None = None,
+ extra_body: Body | None = None,
+ timeout: TimeoutTypes | None = None,
+ ) -> PdfRestFileBasedResponse:
+ """Create a validated ZUGFeRD or Factur-X PDF/A-3 invoice.
+
+ Args:
+ invoice: Uploaded CII invoice XML resource.
+ pdf: Optional uploaded PDF to preserve when its content matches the
+ invoice XML.
+ logo: Optional uploaded PNG or JPEG logo for generated or
+ regenerated visual PDFs.
+ render_options: Optional appearance and regional-formatting options.
+ regenerate_pdf: Allow replacement of the supplied PDF when its
+ content cannot be confirmed against the invoice XML. Requires
+ ``pdf`` when true.
+ 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,
+ "regenerate_pdf": regenerate_pdf,
+ }
+ if pdf is not None:
+ payload["pdf"] = pdf
+ 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=PdfZugferdPayload,
+ extra_query=extra_query,
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
def blank_pdf(
self,
*,
@@ -8151,6 +8216,72 @@ async def sign_pdf(
timeout=timeout,
)
+ async def create_zugferd_pdf(
+ self,
+ invoice: PdfRestFile | Sequence[PdfRestFile],
+ *,
+ pdf: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
+ render_options: PdfZugferdRenderOptions | None = None,
+ regenerate_pdf: bool = False,
+ output: str | None = None,
+ extra_query: Query | None = None,
+ extra_headers: AnyMapping | None = None,
+ extra_body: Body | None = None,
+ timeout: TimeoutTypes | None = None,
+ ) -> PdfRestFileBasedResponse:
+ """Asynchronously create a ZUGFeRD or Factur-X PDF/A-3 invoice.
+
+ This is the asynchronous variant of
+ [PdfRestClient.create_zugferd_pdf][pdfrest.PdfRestClient.create_zugferd_pdf].
+
+ Args:
+ invoice: Uploaded CII invoice XML resource.
+ pdf: Optional uploaded PDF to preserve when its content matches the
+ invoice XML.
+ logo: Optional uploaded PNG or JPEG logo for generated or
+ regenerated visual PDFs.
+ render_options: Optional appearance and regional-formatting options.
+ regenerate_pdf: Allow replacement of the supplied PDF when its
+ content cannot be confirmed against the invoice XML. Requires
+ ``pdf`` when true.
+ 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,
+ "regenerate_pdf": regenerate_pdf,
+ }
+ if pdf is not None:
+ payload["pdf"] = pdf
+ 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=PdfZugferdPayload,
+ 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..21173a6 100644
--- a/src/pdfrest/models/_internal.py
+++ b/src/pdfrest/models/_internal.py
@@ -49,6 +49,11 @@
PdfStructuredTextPageSize,
PdfStructuredTextTextAlignment,
PdfXType,
+ PdfZugferdCurrencyDisplay,
+ PdfZugferdFontName,
+ PdfZugferdLabelLanguage,
+ PdfZugferdLocale,
+ PdfZugferdLogoCorner,
PostScriptLevel,
SummaryFormat,
SummaryOutputFormat,
@@ -1545,6 +1550,289 @@ class ExtractImagesPayload(BaseModel):
CmykChannel = Annotated[int, Field(ge=0, le=100)]
+def _validate_zugferd_label_language(value: str) -> str:
+ trimmed = value.strip()
+ if not re.fullmatch(
+ r"[A-Za-z]{2,3}(?:-(?:[A-Za-z]{4}|[A-Za-z]{2}|\d{3}))?", trimmed
+ ) or not tag_is_valid(trimmed):
+ msg = "label_language must be a valid BCP 47 language tag."
+ raise ValueError(msg)
+ if trimmed.split("-", maxsplit=1)[0].casefold() not in {"en", "de", "fr"}:
+ msg = "label_language must use English, German, or French."
+ raise ValueError(msg)
+ return trimmed
+
+
+def _validate_zugferd_font(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ trimmed = value.strip()
+ if trimmed.casefold() == "webdings":
+ msg = "ZUGFeRD invoice fonts must be text-capable; Webdings is unsupported."
+ raise ValueError(msg)
+ return trimmed
+
+
+_ZugferdFontName = Annotated[
+ PdfZugferdFontName,
+ BeforeValidator(_validate_zugferd_font),
+ Field(min_length=1, max_length=200),
+]
+
+
+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: Annotated[
+ PdfZugferdLabelLanguage | None,
+ AfterValidator(_validate_zugferd_label_language),
+ ] = None
+ currency_display: PdfZugferdCurrencyDisplay | None = None
+ date_format: str | None = None
+ decimal_precision: Annotated[int | None, Field(ge=0, le=4)] = None
+
+
+class PdfZugferdPayload(BaseModel):
+ """Adapt uploaded invoice resources into a ZUGFeRD creation request."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ invoice: 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),
+ ]
+ pdf: Annotated[
+ list[PdfRestFile] | None,
+ 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),
+ ] = None
+ logo: Annotated[
+ list[PdfRestFile] | None,
+ Field(min_length=1, max_length=1, serialization_alias="logo_id"),
+ BeforeValidator(_ensure_list),
+ AfterValidator(
+ _allowed_mime_types(
+ "image/png",
+ "image/jpeg",
+ error_msg="Logo must be a PNG or JPEG file",
+ )
+ ),
+ PlainSerializer(_serialize_as_first_file_id),
+ ] = None
+ render_options: _PdfZugferdRenderOptions | None = None
+ regenerate_pdf: bool = False
+ output: Annotated[
+ str | None,
+ Field(min_length=1),
+ AfterValidator(_validate_output_prefix),
+ ] = None
+
+ @model_validator(mode="after")
+ def _validate_regeneration_source(self) -> PdfZugferdPayload:
+ if self.regenerate_pdf and self.pdf is None:
+ msg = "regenerate_pdf=True requires a PDF resource."
+ 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..62029d4 100644
--- a/src/pdfrest/types/__init__.py
+++ b/src/pdfrest/types/__init__.py
@@ -63,6 +63,13 @@
PdfStructuredTextTextAlignment,
PdfTextColor,
PdfXType,
+ PdfZugferdCurrencyDisplay,
+ PdfZugferdFontName,
+ PdfZugferdKnownFont,
+ PdfZugferdLabelLanguage,
+ PdfZugferdLocale,
+ PdfZugferdLogoCorner,
+ PdfZugferdRenderOptions,
PngColorModel,
PostScriptLevel,
SummaryFormat,
@@ -137,6 +144,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..7782633 100644
--- a/src/pdfrest/types/public.py
+++ b/src/pdfrest/types/public.py
@@ -75,6 +75,13 @@
"PdfStructuredTextTextAlignment",
"PdfTextColor",
"PdfXType",
+ "PdfZugferdCurrencyDisplay",
+ "PdfZugferdFontName",
+ "PdfZugferdKnownFont",
+ "PdfZugferdLabelLanguage",
+ "PdfZugferdLocale",
+ "PdfZugferdLogoCorner",
+ "PdfZugferdRenderOptions",
"PngColorModel",
"PostScriptLevel",
"SummaryFormat",
@@ -159,6 +166,236 @@ class PdfRedactionInstruction(TypedDict):
PdfColor = PdfRGBColor | PdfCMYKColor
PdfTextColor = PdfColor
+PdfZugferdLogoCorner: TypeAlias = Literal[
+ "top-left", "top-right", "bottom-left", "bottom-right"
+]
+"""Logo placement used while rendering a ZUGFeRD invoice.
+
+Accepted values:
+
+- `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.
+"""
+
+PdfZugferdLocale: TypeAlias = Literal["de-DE", "fr-FR", "nl-NL", "en-GB"]
+"""Locale used to format values in a rendered ZUGFeRD invoice.
+
+Accepted values:
+
+- `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.
+"""
+
+PdfZugferdCurrencyDisplay: TypeAlias = Literal["code", "symbol", "name"]
+"""Currency representation used in a rendered ZUGFeRD invoice.
+
+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. Region subtags such
+as ``en-US`` are accepted and normalized by pdfRest to their primary language.
+
+Accepted values:
+
+- `en`: Render English labels.
+- `de`: Render German labels.
+- `fr`: Render French labels.
+"""
+
+PdfZugferdKnownFont: TypeAlias = Literal[
+ "andalemono",
+ "arial",
+ "arialitalic",
+ "arialbold",
+ "arialblack",
+ "calibri",
+ "calibriitalic",
+ "calibribold",
+ "calibribolditalic",
+ "cambriaitalic",
+ "cambriabold",
+ "cambriabolditalic",
+ "candara",
+ "candaraitalic",
+ "candarabold",
+ "candarabolditalic",
+ "cantarellregular",
+ "cantarelloblique",
+ "cantarellbold",
+ "cantarellboldoblique",
+ "comicsans",
+ "comicsansbold",
+ "constantia",
+ "consolas",
+ "consolasitalic",
+ "consolasbold",
+ "consolasbolditalic",
+ "constantiaitalic",
+ "constantiabold",
+ "constantiabolditalic",
+ "corbel",
+ "corbelitalic",
+ "corbelbold",
+ "corbelbolditalic",
+ "courier",
+ "courieritalic",
+ "courierbold",
+ "courierbolditalic",
+ "dejavu",
+ "dejavu sans mono oblique",
+ "dejavu sans mono bold",
+ "dejavu sans mono bold oblique",
+ "georgia",
+ "georgiaitalic",
+ "georgiabold",
+ "georgiabolditalic",
+ "impact",
+ "tahoma",
+ "timesnewroman",
+ "timesnewromanitalic",
+ "timesnewromanbold",
+ "timesnewromanbolditalic",
+ "trebuchet",
+ "trebuchetitalic",
+ "trebuchetbold",
+ "trebuchetbolditalic",
+ "verdana",
+ "verdanaitalic",
+ "verdanabold",
+ "verdanabolditalic",
+]
+"""Published text-capable pdfRest font names for ZUGFeRD rendering.
+
+These are suggestions, not a closed service catalog. Another installed,
+text-capable font may be supplied through ``PdfZugferdFontName``. Symbol fonts
+such as Webdings are not valid for readable invoice text.
+
+Accepted values:
+
+- `andalemono`: Andale Mono.
+- `arial`: Arial.
+- `arialitalic`: Arial Italic.
+- `arialbold`: Arial Bold.
+- `arialblack`: Arial Black.
+- `calibri`: Calibri.
+- `calibriitalic`: Calibri Italic.
+- `calibribold`: Calibri Bold.
+- `calibribolditalic`: Calibri Bold Italic.
+- `cambriaitalic`: Cambria Italic.
+- `cambriabold`: Cambria Bold.
+- `cambriabolditalic`: Cambria Bold Italic.
+- `candara`: Candara.
+- `candaraitalic`: Candara Italic.
+- `candarabold`: Candara Bold.
+- `candarabolditalic`: Candara Bold Italic.
+- `cantarellregular`: Cantarell.
+- `cantarelloblique`: Cantarell Oblique.
+- `cantarellbold`: Cantarell Bold.
+- `cantarellboldoblique`: Cantarell Bold Oblique.
+- `comicsans`: Comic Sans.
+- `comicsansbold`: Comic Sans Bold.
+- `constantia`: Constantia.
+- `consolas`: Consolas.
+- `consolasitalic`: Consolas Italic.
+- `consolasbold`: Consolas Bold.
+- `consolasbolditalic`: Consolas Bold Italic.
+- `constantiaitalic`: Constantia Italic.
+- `constantiabold`: Constantia Bold.
+- `constantiabolditalic`: Constantia Bold Italic.
+- `corbel`: Corbel.
+- `corbelitalic`: Corbel Italic.
+- `corbelbold`: Corbel Bold.
+- `corbelbolditalic`: Corbel Bold Italic.
+- `courier`: Courier New.
+- `courieritalic`: Courier New Italic.
+- `courierbold`: Courier New Bold.
+- `courierbolditalic`: Courier New Bold Italic.
+- `dejavu`: DejaVu Sans Mono.
+- `dejavu sans mono oblique`: DejaVu Sans Mono Oblique.
+- `dejavu sans mono bold`: DejaVu Sans Mono Bold.
+- `dejavu sans mono bold oblique`: DejaVu Sans Mono Bold Oblique.
+- `georgia`: Georgia.
+- `georgiaitalic`: Georgia Italic.
+- `georgiabold`: Georgia Bold.
+- `georgiabolditalic`: Georgia Bold Italic.
+- `impact`: Impact.
+- `tahoma`: Tahoma.
+- `timesnewroman`: Times New Roman.
+- `timesnewromanitalic`: Times New Roman Italic.
+- `timesnewromanbold`: Times New Roman Bold.
+- `timesnewromanbolditalic`: Times New Roman Bold Italic.
+- `trebuchet`: Trebuchet.
+- `trebuchetitalic`: Trebuchet Italic.
+- `trebuchetbold`: Trebuchet Bold.
+- `trebuchetbolditalic`: Trebuchet Bold Italic.
+- `verdana`: Verdana.
+- `verdanaitalic`: Verdana Italic.
+- `verdanabold`: Verdana Bold.
+- `verdanabolditalic`: Verdana Bold Italic.
+"""
+
+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.
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..aa1e835
--- /dev/null
+++ b/tests/live/test_live_create_zugferd_pdf.py
@@ -0,0 +1,247 @@
+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
+ pdf: PdfRestFile
+
+
+RENDER_OPTION_CASES = [
+ pytest.param(
+ PdfZugferdRenderOptions(
+ logo_corner="top-left",
+ font="courier",
+ text_color=(0, 0, 0),
+ locale="de-DE",
+ label_language="en",
+ currency_display="code",
+ decimal_precision=0,
+ ),
+ "top-left-de",
+ id="top-left-german-code",
+ ),
+ pytest.param(
+ PdfZugferdRenderOptions(
+ logo_corner="top-right",
+ font="DejaVu Sans",
+ muted_text_color=(10, 5, 0, 55),
+ locale="fr-FR",
+ label_language="de",
+ currency_display="symbol",
+ decimal_precision=1,
+ ),
+ "top-right-fr",
+ id="top-right-french-symbol",
+ ),
+ pytest.param(
+ PdfZugferdRenderOptions(
+ logo_corner="bottom-left",
+ accent_color=(20, 64, 115),
+ locale="nl-NL",
+ label_language="fr",
+ currency_display="name",
+ decimal_precision=4,
+ ),
+ "bottom-left-nl",
+ id="bottom-left-dutch-name",
+ ),
+ pytest.param(
+ PdfZugferdRenderOptions(
+ logo_corner="bottom-right",
+ table_header_color=(83, 44, 0, 55),
+ locale="en-GB",
+ label_language="en-US",
+ currency_display="code",
+ date_format="dd/MM/yyyy",
+ decimal_precision=2,
+ ),
+ "bottom-right-en",
+ id="bottom-right-english-region",
+ ),
+]
+
+
+@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, pdf = client.files.create_from_paths(paths)
+ return ZugferdResources(invoice=invoice, logo=logo, pdf=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-")
+
+
+@pytest.mark.parametrize(("render_options", "output"), RENDER_OPTION_CASES)
+def test_live_create_zugferd_pdf_render_options(
+ 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.create_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"), RENDER_OPTION_CASES)
+@pytest.mark.asyncio
+async def test_live_async_create_zugferd_pdf_render_options(
+ 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.create_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_create_zugferd_pdf_regenerates_mismatched_pdf(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ pdf = uploaded_zugferd_resources["pdf"]
+ logo = uploaded_zugferd_resources["logo"]
+ with PdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ response = client.create_zugferd_pdf(
+ invoice,
+ pdf=pdf,
+ logo=logo,
+ regenerate_pdf=True,
+ output="regenerated-zugferd",
+ )
+ output_bytes = client.files.read_bytes(response.output_file)
+
+ assert response.input_ids == [invoice.id, pdf.id, logo.id]
+ _assert_zugferd_output(response.output_file, output_bytes, "regenerated-zugferd")
+
+
+@pytest.mark.asyncio
+async def test_live_async_create_zugferd_pdf_regenerates_mismatched_pdf(
+ pdfrest_api_key: str,
+ pdfrest_live_base_url: str,
+ uploaded_zugferd_resources: ZugferdResources,
+) -> None:
+ invoice = uploaded_zugferd_resources["invoice"]
+ pdf = uploaded_zugferd_resources["pdf"]
+ logo = uploaded_zugferd_resources["logo"]
+ async with AsyncPdfRestClient(
+ api_key=pdfrest_api_key,
+ base_url=pdfrest_live_base_url,
+ ) as client:
+ response = await client.create_zugferd_pdf(
+ invoice,
+ pdf=pdf,
+ logo=logo,
+ regenerate_pdf=True,
+ output="async-regenerated-zugferd",
+ )
+ output_bytes = await client.files.read_bytes(response.output_file)
+
+ assert response.input_ids == [invoice.id, pdf.id, logo.id]
+ _assert_zugferd_output(
+ response.output_file, output_bytes, "async-regenerated-zugferd"
+ )
+
+
+def test_live_create_zugferd_pdf_rejects_invalid_render_option(
+ 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)locale|invalid"),
+ ):
+ client.create_zugferd_pdf(
+ uploaded_zugferd_resources["invoice"],
+ extra_body={"render_options": {"locale": "xx-XX"}},
+ )
+
+
+@pytest.mark.asyncio
+async def test_live_async_create_zugferd_pdf_rejects_invalid_render_option(
+ 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)locale|invalid"):
+ await client.create_zugferd_pdf(
+ uploaded_zugferd_resources["invoice"],
+ extra_body={"render_options": {"locale": "xx-XX"}},
+ )
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_create_zugferd_pdf.py b/tests/test_create_zugferd_pdf.py
new file mode 100644
index 0000000..b877853
--- /dev/null
+++ b/tests/test_create_zugferd_pdf.py
@@ -0,0 +1,642 @@
+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 ValidationError
+
+from pdfrest import AsyncPdfRestClient, PdfRestClient
+from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID
+from pdfrest.models._internal import PdfZugferdPayload
+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(payload: dict[str, Any]) -> dict[str, Any]:
+ return PdfZugferdPayload.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 test_zugferd_payload_serializes_resources_and_render_options() -> None:
+ invoice = _make_file("invoice.xml", "application/xml", prefix=1)
+ pdf = _make_file("invoice.pdf", "application/pdf")
+ logo = _make_file("logo.png", "image/png", prefix=2)
+
+ payload = _dump(
+ {
+ "invoice": invoice,
+ "pdf": pdf,
+ "logo": logo,
+ "regenerate_pdf": True,
+ "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": "en-US",
+ "currency_display": "symbol",
+ "date_format": "dd.MM.yyyy",
+ "decimal_precision": 3,
+ },
+ }
+ )
+
+ assert payload == {
+ "id": str(invoice.id),
+ "pdf_id": str(pdf.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-US",
+ "currency_display": "symbol",
+ "date_format": "dd.MM.yyyy",
+ "decimal_precision": 3,
+ },
+ "regenerate_pdf": True,
+ "output": "customer-invoice",
+ }
+
+
+@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({"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({"invoice": invoice, "render_options": {"font": font}})
+
+ assert payload["render_options"] == {"font": font}
+
+
+@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
+ ),
+ pytest.param("label_language", "en", id="language-en"),
+ pytest.param("label_language", "de", id="language-de"),
+ pytest.param("label_language", "fr", id="language-fr"),
+ pytest.param("label_language", "fr-FR", id="language-region"),
+ ],
+)
+def test_zugferd_payload_accepts_render_option_literals(field: str, value: str) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ payload = _dump({"invoice": invoice, "render_options": {field: value}})
+
+ assert payload["render_options"] == {field: value}
+
+
+@pytest.mark.parametrize(
+ "decimal_precision",
+ [pytest.param(0, id="minimum"), pytest.param(4, id="maximum")],
+)
+def test_zugferd_payload_accepts_decimal_precision_boundaries(
+ decimal_precision: int,
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ payload = _dump(
+ {
+ "invoice": invoice,
+ "render_options": {"decimal_precision": decimal_precision},
+ }
+ )
+
+ assert payload["render_options"]["decimal_precision"] == decimal_precision
+
+
+@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": (0, 256, 0)}, "less than or equal to 255", id="rgb-high"
+ ),
+ 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 item", id="font-empty"),
+ pytest.param({"font": "x" * 201}, "at most 200 items", 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": "not_a_tag"}, "valid BCP 47", id="language-shape"
+ ),
+ pytest.param(
+ {"unsupported": True}, "Extra inputs are not permitted", id="unknown-field"
+ ),
+ ],
+)
+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):
+ PdfZugferdPayload.model_validate(
+ {"invoice": invoice, "render_options": options}
+ )
+
+
+def test_zugferd_payload_accepts_custom_installed_font() -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ payload = _dump({"invoice": invoice, "render_options": {"font": "Corporate Sans"}})
+
+ assert payload["render_options"] == {"font": "Corporate Sans"}
+
+
+@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": _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"),
+ "logo": _make_file("logo.gif", "image/gif"),
+ },
+ "Logo must be a PNG or JPEG file",
+ id="logo-mime",
+ ),
+ pytest.param(
+ {
+ "invoice": [
+ _make_file("one.xml", "application/xml"),
+ _make_file("two.xml", "text/xml"),
+ ]
+ },
+ "at most 1 item",
+ id="invoice-cardinality",
+ ),
+ pytest.param(
+ {
+ "invoice": _make_file("invoice.xml", "application/xml"),
+ "regenerate_pdf": True,
+ },
+ "requires a PDF resource",
+ id="regeneration-dependency",
+ ),
+ ],
+)
+def test_zugferd_payload_rejects_invalid_resources(
+ payload: dict[str, Any], error_match: str
+) -> None:
+ with pytest.raises(ValidationError, match=error_match):
+ PdfZugferdPayload.model_validate(payload)
+
+
+def test_create_zugferd_pdf_success_and_response_mapping() -> None:
+ invoice = _make_file("invoice.xml", "application/xml", prefix=1)
+ pdf = _make_file("invoice.pdf", "application/pdf")
+ logo = _make_file("logo.jpg", "image/jpeg", prefix=2)
+ output_id = PdfRestFileID.generate()
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "POST":
+ assert request.url.path == "/zugferd-pdf"
+ assert request.headers["Api-Key"] == VALID_API_KEY
+ assert json.loads(request.content) == {
+ "id": str(invoice.id),
+ "pdf_id": str(pdf.id),
+ "logo_id": str(logo.id),
+ "render_options": {
+ "logo_corner": "bottom-right",
+ "text_color_rgb": [1, 2, 3],
+ "accent_color_cmyk": [4, 3, 2, 1],
+ },
+ "regenerate_pdf": True,
+ "output": "zugferd-result",
+ }
+ return httpx.Response(
+ 200,
+ json={
+ "inputId": [str(invoice.id), str(pdf.id), str(logo.id)],
+ "outputId": str(output_id),
+ "outputUrl": f"https://api.pdfrest.com/resource/{output_id}",
+ },
+ )
+ assert request.method == "GET"
+ assert request.url.path == f"/resource/{output_id}"
+ return httpx.Response(
+ 200,
+ json=build_file_info_payload(
+ str(output_id), "zugferd-result.pdf", "application/pdf"
+ ),
+ )
+
+ options: PdfZugferdRenderOptions = {
+ "logo_corner": "bottom-right",
+ "text_color": (1, 2, 3),
+ "accent_color": (4, 3, 2, 1),
+ }
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = client.create_zugferd_pdf(
+ invoice,
+ pdf=pdf,
+ logo=logo,
+ render_options=options,
+ regenerate_pdf=True,
+ output="zugferd-result",
+ )
+
+ assert isinstance(response, PdfRestFileBasedResponse)
+ assert response.input_ids == [invoice.id, pdf.id, logo.id]
+ assert response.output_file.id == output_id
+ assert response.output_file.name == "zugferd-result.pdf"
+ assert response.output_file.type == "application/pdf"
+
+
+@pytest.mark.asyncio
+async def test_async_create_zugferd_pdf_success_and_response_mapping() -> None:
+ invoice = _make_file("invoice.xml", "text/xml", prefix=1)
+ pdf = _make_file("invoice.pdf", "application/pdf")
+ logo = _make_file("logo.png", "image/png", prefix=2)
+ output_id = PdfRestFileID.generate()
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ if request.method == "POST":
+ assert request.url.path == "/zugferd-pdf"
+ assert request.headers["Api-Key"] == ASYNC_API_KEY
+ assert json.loads(request.content) == {
+ "id": str(invoice.id),
+ "pdf_id": str(pdf.id),
+ "logo_id": str(logo.id),
+ "render_options": {
+ "font": "Corporate Sans",
+ "bold_font": "arialbold",
+ "muted_text_color_cmyk": [0, 0, 0, 50],
+ "locale": "fr-FR",
+ "label_language": "fr-FR",
+ "currency_display": "name",
+ "decimal_precision": 4,
+ },
+ "regenerate_pdf": True,
+ "output": "zugferd-async",
+ }
+ return httpx.Response(
+ 200,
+ json={
+ "inputId": [str(invoice.id), str(pdf.id), str(logo.id)],
+ "outputId": str(output_id),
+ "outputUrl": f"https://api.pdfrest.com/resource/{output_id}",
+ },
+ )
+ assert request.method == "GET"
+ assert request.url.path == f"/resource/{output_id}"
+ return httpx.Response(
+ 200,
+ json=build_file_info_payload(
+ str(output_id), "zugferd-async.pdf", "application/pdf"
+ ),
+ )
+
+ options: PdfZugferdRenderOptions = {
+ "font": "Corporate Sans",
+ "bold_font": "arialbold",
+ "muted_text_color": (0, 0, 0, 50),
+ "locale": "fr-FR",
+ "label_language": "fr-FR",
+ "currency_display": "name",
+ "decimal_precision": 4,
+ }
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = await client.create_zugferd_pdf(
+ invoice,
+ pdf=pdf,
+ logo=logo,
+ render_options=options,
+ regenerate_pdf=True,
+ output="zugferd-async",
+ )
+
+ assert response.input_ids == [invoice.id, pdf.id, logo.id]
+ assert response.output_file.id == output_id
+ assert response.output_file.name == "zugferd-async.pdf"
+
+
+def test_create_zugferd_pdf_request_customization() -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ 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)
+ assert json.loads(request.content) == {
+ "id": str(invoice.id),
+ "regenerate_pdf": False,
+ "server_debug": True,
+ }
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = client.create_zugferd_pdf(
+ invoice,
+ extra_query={"trace": "sync"},
+ extra_headers={"X-Trace": "zugferd"},
+ extra_body={"server_debug": True},
+ timeout=14.0,
+ )
+
+ assert response.input_id == invoice.id
+ assert response.output_files == []
+
+
+@pytest.mark.asyncio
+async def test_async_create_zugferd_pdf_request_customization() -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ 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) == {
+ "id": str(invoice.id),
+ "regenerate_pdf": False,
+ "server_debug": True,
+ }
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ async with AsyncPdfRestClient(
+ api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ response = await client.create_zugferd_pdf(
+ invoice,
+ extra_query={"trace": "async"},
+ extra_headers={"X-Trace": "zugferd-async"},
+ extra_body={"server_debug": True},
+ timeout=21.0,
+ )
+
+ assert response.input_id == invoice.id
+ assert response.output_files == []
+
+
+@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str)
+def test_create_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.create_zugferd_pdf(invoice, render_options={"font": font})
+
+
+@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str)
+@pytest.mark.asyncio
+async def test_async_create_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.create_zugferd_pdf(invoice, render_options={"font": font})
+
+
+@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
+ ),
+ pytest.param("label_language", "en", id="language-en"),
+ pytest.param("label_language", "de", id="language-de"),
+ pytest.param("label_language", "fr", id="language-fr"),
+ ],
+)
+def test_create_zugferd_pdf_accepts_every_render_literal(
+ field: str, value: str
+) -> None:
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert json.loads(request.content)["render_options"] == {field: value}
+ return httpx.Response(200, json={"inputId": str(invoice.id)})
+
+ with PdfRestClient(
+ api_key=VALID_API_KEY, transport=httpx.MockTransport(handler)
+ ) as client:
+ client.create_zugferd_pdf(
+ invoice,
+ render_options=cast(PdfZugferdRenderOptions, cast(object, {field: value})),
+ )
+
+
+@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
+ ),
+ pytest.param("label_language", "en", id="language-en"),
+ pytest.param("label_language", "de", id="language-de"),
+ pytest.param("label_language", "fr", id="language-fr"),
+ ],
+)
+@pytest.mark.asyncio
+async def test_async_create_zugferd_pdf_accepts_every_render_literal(
+ field: str, value: 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: value}
+ 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.create_zugferd_pdf(
+ invoice,
+ render_options=cast(PdfZugferdRenderOptions, cast(object, {field: value})),
+ )
+
+
+def test_create_zugferd_pdf_rejects_invalid_resource_before_transport() -> None:
+ invoice = _make_file("invoice.pdf", "application/pdf")
+
+ 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="Invoice must be an XML file"),
+ ):
+ client.create_zugferd_pdf(invoice)
+
+
+@pytest.mark.asyncio
+async def test_async_create_zugferd_pdf_rejects_invalid_resource_before_transport() -> (
+ None
+):
+ invoice = _make_file("invoice.xml", "application/xml")
+
+ 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="requires a PDF resource"):
+ await client.create_zugferd_pdf(invoice, regenerate_pdf=True)