From 9c3ea1201f97ea41174a5977e274d2d5bd4e769e Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 20 Oct 2026 13:45:11 -0500 Subject: [PATCH 1/8] models: Define ZUGFeRD PDF request contract - Add typed rendering options for invoice appearance and localization. - Validate uploaded resources, color channels, fonts, and regeneration. - Serialize the public input shape into the pdfRest wire contract. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 288 ++++++++++++++++++++++++++++++++ src/pdfrest/types/__init__.py | 14 ++ src/pdfrest/types/public.py | 237 ++++++++++++++++++++++++++ 3 files changed, 539 insertions(+) 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. From a419785c4b82482126f70ae163a54cd93d438a08 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 20 Oct 2026 13:45:45 -0500 Subject: [PATCH 2/8] client: Add ZUGFeRD PDF creation - Add synchronous and asynchronous helpers for uploaded invoice XML. - Support optional PDFs, logos, rendering controls, and request options. - Cover serialization, validation, response mapping, and both transports. Assisted-by: Codex --- src/pdfrest/client.py | 131 +++++++ tests/test_create_zugferd_pdf.py | 642 +++++++++++++++++++++++++++++++ 2 files changed, 773 insertions(+) create mode 100644 tests/test_create_zugferd_pdf.py 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/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) From 97b5d69614a92198c754bc134fbb80ac5eadfc43 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 20 Oct 2026 13:46:07 -0500 Subject: [PATCH 3/8] tests: Verify ZUGFeRD PDF creation live - Exercise every closed rendering option through both client transports. - Verify regenerated PDFs, response metadata, and server-side rejection. - Add a deterministic minimum-profile invoice fixture. Assisted-by: Codex --- tests/live/test_live_create_zugferd_pdf.py | 247 ++++++++++++++++++++ tests/resources/zugferd-minimum-invoice.xml | 51 ++++ 2 files changed, 298 insertions(+) create mode 100644 tests/live/test_live_create_zugferd_pdf.py create mode 100644 tests/resources/zugferd-minimum-invoice.xml 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 + + + + From 7d951b6291e4dad1395286f1ea5b20bfa2833f89 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 20 Oct 2026 13:46:42 -0500 Subject: [PATCH 4/8] examples: Demonstrate ZUGFeRD PDF creation - Add a standalone typed example with a deterministic invoice fixture. - Verify the generated PDF and print useful response metadata. - Link the helper from the API guide and example inventories. Assisted-by: Codex --- docs/api-guide.md | 3 + docs/llms-full.txt | 2 + examples/README.md | 2 + .../create_zugferd_pdf_example.py | 72 +++++++++++++++++++ .../resources/zugferd-minimum-invoice.xml | 51 +++++++++++++ 5 files changed, 130 insertions(+) create mode 100644 examples/create_zugferd_pdf/create_zugferd_pdf_example.py create mode 100644 examples/resources/zugferd-minimum-invoice.xml 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 + + + + From 858153ea688710c78b4c52af842c735d880f870e Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Mon, 2 Nov 2026 14:24:55 -0600 Subject: [PATCH 5/8] client: Split ZUGFeRD conversion workflows - Separate XML rendering from supplied-PDF preservation and require explicit permission for replacement-only options. - Canonicalize supported label-language aliases and share the readable font catalog across conversion APIs. - Cover sync and async serialization, validation, response handling, and request customization. Assisted-by: Codex --- src/pdfrest/client.py | 166 +++- src/pdfrest/models/_internal.py | 191 ++-- src/pdfrest/types/__init__.py | 2 + src/pdfrest/types/public.py | 155 +--- ...est_convert_structured_documents_to_pdf.py | 3 +- tests/test_create_zugferd_pdf.py | 841 +++++++++++++----- 6 files changed, 857 insertions(+), 501 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 94878ae..f75e2c8 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -112,6 +112,7 @@ PdfImageWatermarkPayload, PdfImportFormDataPayload, PdfInfoPayload, + PdfInvoiceXmlToZugferdPayload, PdfLinearizePayload, PdfMergePayload, PdfRasterizePayload, @@ -127,9 +128,9 @@ PdfToPdfxPayload, PdfToPowerpointPayload, PdfToWordPayload, + PdfToZugferdPayload, PdfUnrestrictPayload, PdfXfaToAcroformsPayload, - PdfZugferdPayload, PngPdfRestPayload, SummarizePdfTextPayload, TiffPdfRestPayload, @@ -4617,32 +4618,24 @@ def sign_pdf( timeout=timeout, ) - def create_zugferd_pdf( + def convert_invoice_xml_to_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. + """Convert CII invoice XML into a 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. + logo: Optional uploaded PNG or JPEG logo for the generated PDF. 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. @@ -4653,16 +4646,75 @@ def create_zugferd_pdf( 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, - "regenerate_pdf": regenerate_pdf, + "pdf": pdf, + "regenerate_pdf": allow_regeneration, } - if pdf is not None: - payload["pdf"] = pdf if logo is not None: payload["logo"] = logo if render_options is not None: @@ -4673,7 +4725,7 @@ def create_zugferd_pdf( return self._post_file_operation( endpoint="/zugferd-pdf", payload=payload, - payload_model=PdfZugferdPayload, + payload_model=PdfToZugferdPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, @@ -8216,35 +8268,27 @@ async def sign_pdf( timeout=timeout, ) - async def create_zugferd_pdf( + async def convert_invoice_xml_to_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. + """Convert CII XML into a ZUGFeRD PDF/A-3 invoice asynchronously. This is the asynchronous variant of - [PdfRestClient.create_zugferd_pdf][pdfrest.PdfRestClient.create_zugferd_pdf]. + [PdfRestClient.convert_invoice_xml_to_zugferd_pdf][pdfrest.PdfRestClient.convert_invoice_xml_to_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. + logo: Optional uploaded PNG or JPEG logo for the generated PDF. 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. @@ -8255,16 +8299,74 @@ async def create_zugferd_pdf( 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, - "regenerate_pdf": regenerate_pdf, + "pdf": pdf, + "regenerate_pdf": allow_regeneration, } - if pdf is not None: - payload["pdf"] = pdf if logo is not None: payload["logo"] = logo if render_options is not None: @@ -8275,7 +8377,7 @@ async def create_zugferd_pdf( return await self._post_file_operation( endpoint="/zugferd-pdf", payload=payload, - payload_model=PdfZugferdPayload, + payload_model=PdfToZugferdPayload, extra_query=extra_query, extra_headers=extra_headers, extra_body=extra_body, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 21173a6..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, @@ -50,8 +50,6 @@ PdfStructuredTextTextAlignment, PdfXType, PdfZugferdCurrencyDisplay, - PdfZugferdFontName, - PdfZugferdLabelLanguage, PdfZugferdLocale, PdfZugferdLogoCorner, PostScriptLevel, @@ -334,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)$" @@ -1550,33 +1534,38 @@ 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): +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) - if trimmed.split("-", maxsplit=1)[0].casefold() not in {"en", "de", "fr"}: + 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 trimmed + return primary_language -def _validate_zugferd_font(value: Any) -> Any: - if not isinstance(value, str): - return value - trimmed = value.strip() - if trimmed.casefold() == "webdings": +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 trimmed + return value _ZugferdFontName = Annotated[ - PdfZugferdFontName, - BeforeValidator(_validate_zugferd_font), - Field(min_length=1, max_length=200), + 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), ] @@ -1768,67 +1757,93 @@ class _PdfZugferdRenderOptions(BaseModel): ] = 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 + 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 -class PdfZugferdPayload(BaseModel): - """Adapt uploaded invoice resources into a ZUGFeRD creation request.""" +_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: 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") - ), + 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[ - 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", - ) - ), + _ZugferdLogoFiles | None, + Field(serialization_alias="logo_id"), 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 + output: _ZugferdOutput | None = 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." + 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 diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 62029d4..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, @@ -111,6 +112,7 @@ "PdfConversionLocale", "PdfCustomPageSize", "PdfInfoQuery", + "PdfKnownTextFont", "PdfMergeInput", "PdfMergeSource", "PdfPageOrientation", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 7782633..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", @@ -203,8 +204,9 @@ class PdfRedactionInstruction(TypedDict): 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. +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: @@ -213,7 +215,7 @@ class PdfRedactionInstruction(TypedDict): - `fr`: Render French labels. """ -PdfZugferdKnownFont: TypeAlias = Literal[ +PdfKnownTextFont: TypeAlias = Literal[ "andalemono", "arial", "arialitalic", @@ -275,11 +277,11 @@ class PdfRedactionInstruction(TypedDict): "verdanabold", "verdanabolditalic", ] -"""Published text-capable pdfRest font names for ZUGFeRD rendering. +"""Published text-capable pdfRest font names. -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. +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: @@ -345,6 +347,14 @@ class PdfRedactionInstruction(TypedDict): - `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.""" @@ -433,137 +443,16 @@ class PdfZugferdRenderOptions(TypedDict, total=False): - `Tabloid`: Tabloid paper, 792 by 1224 PDF points. """ -PdfStructuredTextKnownFont: 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", - "webdings", -] +PdfStructuredTextKnownFont: TypeAlias = PdfKnownTextFont | Literal["webdings"] """Published pdfRest font names for structured document conversion. -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/). +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: -- `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. +- Every value documented by ``PdfKnownTextFont``: text-capable installed fonts. - `webdings`: Webdings. """ 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 index b877853..9384bee 100644 --- a/tests/test_create_zugferd_pdf.py +++ b/tests/test_create_zugferd_pdf.py @@ -6,11 +6,14 @@ import httpx import pytest -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID -from pdfrest.models._internal import PdfZugferdPayload +from pdfrest.models._internal import ( + PdfInvoiceXmlToZugferdPayload, + PdfToZugferdPayload, +) from pdfrest.types import ( PdfZugferdCurrencyDisplay, PdfZugferdKnownFont, @@ -50,8 +53,8 @@ def _make_file(name: str, mime_type: str, *, prefix: int | None = None) -> PdfRe ) -def _dump(payload: dict[str, Any]) -> dict[str, Any]: - return PdfZugferdPayload.model_validate(payload).model_dump( +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 ) @@ -65,17 +68,32 @@ def _assert_timeout(value: float | dict[str, float] | None, expected: float) -> assert value == pytest.approx(expected) -def test_zugferd_payload_serializes_resources_and_render_options() -> None: +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) - pdf = _make_file("invoice.pdf", "application/pdf") logo = _make_file("logo.png", "image/png", prefix=2) payload = _dump( + PdfInvoiceXmlToZugferdPayload, { "invoice": invoice, - "pdf": pdf, "logo": logo, - "regenerate_pdf": True, "output": "customer-invoice", "render_options": { "logo_corner": "top-right", @@ -89,17 +107,16 @@ def test_zugferd_payload_serializes_resources_and_render_options() -> None: "table_alternate_color": (4, 2, 0, 2), "footer_message": "Thank you for your business.", "locale": "de-DE", - "label_language": "en-US", + "label_language": "eng-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", @@ -113,16 +130,37 @@ def test_zugferd_payload_serializes_resources_and_render_options() -> None: "table_alternate_color_cmyk": [4, 2, 0, 2], "footer_message": "Thank you for your business.", "locale": "de-DE", - "label_language": "en-US", + "label_language": "en", "currency_display": "symbol", "date_format": "dd.MM.yyyy", "decimal_precision": 3, }, - "regenerate_pdf": True, "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", [ @@ -147,9 +185,10 @@ def test_zugferd_payload_routes_unified_colors( wire_suffix: str, ) -> None: invoice = _make_file("invoice.xml", "text/xml") - - payload = _dump({"invoice": invoice, "render_options": {field: color}}) - + payload = _dump( + PdfInvoiceXmlToZugferdPayload, + {"invoice": invoice, "render_options": {field: color}}, + ) assert payload["render_options"] == {f"{field}_{wire_suffix}": list(color)} @@ -158,10 +197,34 @@ 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} - payload = _dump({"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( @@ -176,38 +239,39 @@ def test_zugferd_payload_accepts_every_published_font( 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: +def test_zugferd_payload_accepts_every_closed_literal(field: str, value: str) -> None: invoice = _make_file("invoice.xml", "application/xml") - - payload = _dump({"invoice": invoice, "render_options": {field: value}}) - + payload = _dump( + PdfInvoiceXmlToZugferdPayload, + {"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")], + "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_decimal_precision_boundaries( - decimal_precision: int, -) -> None: +def test_zugferd_payload_accepts_constraint_boundaries(options: dict[str, Any]) -> None: invoice = _make_file("invoice.xml", "application/xml") - - payload = _dump( - { - "invoice": invoice, - "render_options": {"decimal_precision": decimal_precision}, - } + _dump( + PdfInvoiceXmlToZugferdPayload, + {"invoice": invoice, "render_options": options}, ) - assert payload["render_options"]["decimal_precision"] == decimal_precision - @pytest.mark.parametrize( ("options", "error_match"), @@ -218,15 +282,21 @@ def test_zugferd_payload_accepts_decimal_precision_boundaries( 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 item", id="font-empty"), - pytest.param({"font": "x" * 201}, "at most 200 items", id="font-long"), + 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" @@ -237,33 +307,26 @@ def test_zugferd_payload_accepts_decimal_precision_boundaries( 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" + {"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, + 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( + PdfInvoiceXmlToZugferdPayload.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"), [ @@ -272,13 +335,16 @@ def test_zugferd_payload_accepts_custom_installed_font() -> None: "Invoice must be an XML file", id="invoice-mime", ), + pytest.param({"invoice": []}, "at least 1 item", id="invoice-empty"), pytest.param( { - "invoice": _make_file("invoice.xml", "application/xml"), - "pdf": _make_file("source.png", "image/png"), + "invoice": [ + _make_file("one.xml", "application/xml"), + _make_file("two.xml", "text/xml"), + ] }, - "Must be a PDF file", - id="pdf-mime", + "at most 1 item", + id="invoice-extra", ), pytest.param( { @@ -288,65 +354,120 @@ def test_zugferd_payload_accepts_custom_installed_font() -> None: "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("one.xml", "application/xml"), - _make_file("two.xml", "text/xml"), - ] + "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="invoice-cardinality", + id="logo-extra", ), pytest.param( { "invoice": _make_file("invoice.xml", "application/xml"), - "regenerate_pdf": True, + "output": "../unsafe", }, - "requires a PDF resource", - id="regeneration-dependency", + "directory separator", + id="unsafe-output", ), ], ) -def test_zugferd_payload_rejects_invalid_resources( +def test_invoice_xml_payload_rejects_invalid_resources( payload: dict[str, Any], error_match: str ) -> None: with pytest.raises(ValidationError, match=error_match): - PdfZugferdPayload.model_validate(payload) + PdfInvoiceXmlToZugferdPayload.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) +@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": - assert request.url.path == "/zugferd-pdf" - assert request.headers["Api-Key"] == VALID_API_KEY + counts["post"] += 1 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, + "render_options": {"font": "arial", "label_language": "en"}, "output": "zugferd-result", } return httpx.Response( 200, json={ - "inputId": [str(invoice.id), str(pdf.id), str(logo.id)], + "inputId": [str(invoice.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}" + counts["get"] += 1 return httpx.Response( 200, json=build_file_info_payload( @@ -354,67 +475,48 @@ def handler(request: httpx.Request) -> httpx.Response: ), ) - 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( + response = client.convert_invoice_xml_to_zugferd_pdf( invoice, - pdf=pdf, logo=logo, - render_options=options, - regenerate_pdf=True, + render_options={"font": "arial", "label_language": "eng-US"}, 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" + 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_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) +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": - 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", - } + counts["post"] += 1 + assert json.loads(request.content)["logo_id"] == str(logo.id) return httpx.Response( 200, json={ - "inputId": [str(invoice.id), str(pdf.id), str(logo.id)], + "inputId": [str(invoice.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}" + counts["get"] += 1 return httpx.Response( 200, json=build_file_info_payload( @@ -422,93 +524,128 @@ async def handler(request: httpx.Request) -> httpx.Response: ), ) - 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", + response = await client.convert_invoice_xml_to_zugferd_pdf( + invoice, logo=logo, 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" + 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_create_zugferd_pdf_request_customization() -> None: - invoice = _make_file("invoice.xml", "application/xml") +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: - 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)}) + 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.create_zugferd_pdf( - invoice, - extra_query={"trace": "sync"}, - extra_headers={"X-Trace": "zugferd"}, - extra_body={"server_debug": True}, - timeout=14.0, + response = client.convert_pdf_to_zugferd_pdf( + pdf, invoice=invoice, output="preserved" ) - assert response.input_id == invoice.id - assert response.output_files == [] + 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_create_zugferd_pdf_request_customization() -> None: - invoice = _make_file("invoice.xml", "application/xml") +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: - 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)}) + 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.create_zugferd_pdf( - invoice, - extra_query={"trace": "async"}, - extra_headers={"X-Trace": "zugferd-async"}, - extra_body={"server_debug": True}, - timeout=21.0, + 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 response.input_id == invoice.id - assert response.output_files == [] + 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_create_zugferd_pdf_accepts_every_published_font( +def test_convert_invoice_xml_to_zugferd_pdf_accepts_every_published_font( font: PdfZugferdKnownFont, ) -> None: invoice = _make_file("invoice.xml", "application/xml") @@ -520,12 +657,14 @@ def handler(request: httpx.Request) -> httpx.Response: with PdfRestClient( api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) ) as client: - client.create_zugferd_pdf(invoice, render_options={"font": font}) + 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_create_zugferd_pdf_accepts_every_published_font( +async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_every_published_font( font: PdfZugferdKnownFont, ) -> None: invoice = _make_file("invoice.xml", "application/xml") @@ -537,83 +676,129 @@ async def handler(request: httpx.Request) -> httpx.Response: async with AsyncPdfRestClient( api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) ) as client: - await client.create_zugferd_pdf(invoice, render_options={"font": font}) + await client.convert_invoice_xml_to_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 +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: value} + 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.create_zugferd_pdf( - invoice, - render_options=cast(PdfZugferdRenderOptions, cast(object, {field: value})), - ) + client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options) -@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.parametrize(("field", "value", "expected"), CLIENT_RENDER_CASES) @pytest.mark.asyncio -async def test_async_create_zugferd_pdf_accepts_every_render_literal( - field: str, value: str +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: value} + 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.create_zugferd_pdf( + 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, - render_options=cast(PdfZugferdRenderOptions, cast(object, {field: value})), - ) - + { + "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) -def test_create_zugferd_pdf_rejects_invalid_resource_before_transport() -> None: - invoice = _make_file("invoice.pdf", "application/pdf") +@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") @@ -621,22 +806,184 @@ def handler(_: httpx.Request) -> httpx.Response: PdfRestClient( api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) ) as client, - pytest.raises(ValidationError, match="Invoice must be an XML file"), + pytest.raises(ValidationError, match=error_match), ): - client.create_zugferd_pdf(invoice) + _invoke_invalid_generation(client, case) +@pytest.mark.parametrize(("case", "error_match"), INVALID_GENERATION_CASES) @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 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="requires a PDF resource"): - await client.create_zugferd_pdf(invoice, regenerate_pdf=True) + 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 == [] From cc07365ed5565d4eeef98133582d1dda25c47d51 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Mon, 2 Nov 2026 14:25:13 -0600 Subject: [PATCH 6/8] tests: Expand ZUGFeRD live behavior coverage - Exercise every closed render-option value through sync and async clients. - Verify visual preservation, observable rendering changes, mismatch rejection, and explicitly permitted replacement. - Send invalid option boundaries to the service and assert response metadata and warnings. Assisted-by: Codex --- tests/live/test_live_create_zugferd_pdf.py | 445 +++++++++++++++++---- 1 file changed, 366 insertions(+), 79 deletions(-) diff --git a/tests/live/test_live_create_zugferd_pdf.py b/tests/live/test_live_create_zugferd_pdf.py index aa1e835..c5c1069 100644 --- a/tests/live/test_live_create_zugferd_pdf.py +++ b/tests/live/test_live_create_zugferd_pdf.py @@ -14,60 +14,81 @@ class ZugferdResources(TypedDict): invoice: PdfRestFile logo: PdfRestFile - pdf: PdfRestFile + mismatched_pdf: PdfRestFile + matching_pdf: PdfRestFile -RENDER_OPTION_CASES = [ +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( - 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", + PdfZugferdRenderOptions(decimal_precision=0), + "precision-minimum", + id="precision-minimum", ), 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", + PdfZugferdRenderOptions(decimal_precision=4), + "precision-maximum", + id="precision-maximum", ), +] + +INVALID_RENDER_OPTION_CASES = [ 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", + {"logo_corner": "center"}, r"(?i)logo.corner|invalid", id="logo-corner" ), + pytest.param({"currency_display": "iso"}, r"(?i)currency|invalid", id="currency"), 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", + {"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" ), ] @@ -86,8 +107,17 @@ def uploaded_zugferd_resources( 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) + 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( @@ -104,8 +134,34 @@ def _assert_zugferd_output( assert output_bytes.startswith(b"%PDF-") -@pytest.mark.parametrize(("render_options", "output"), RENDER_OPTION_CASES) -def test_live_create_zugferd_pdf_render_options( +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, @@ -118,7 +174,7 @@ def test_live_create_zugferd_pdf_render_options( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - response = client.create_zugferd_pdf( + response = client.convert_invoice_xml_to_zugferd_pdf( invoice, logo=logo, render_options=render_options, @@ -131,9 +187,9 @@ def test_live_create_zugferd_pdf_render_options( _assert_zugferd_output(response.output_file, output_bytes, output) -@pytest.mark.parametrize(("render_options", "output"), RENDER_OPTION_CASES) +@pytest.mark.parametrize(("render_options", "output"), ISOLATED_RENDER_OPTION_CASES) @pytest.mark.asyncio -async def test_live_async_create_zugferd_pdf_render_options( +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, @@ -146,7 +202,7 @@ async def test_live_async_create_zugferd_pdf_render_options( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - response = await client.create_zugferd_pdf( + response = await client.convert_invoice_xml_to_zugferd_pdf( invoice, logo=logo, render_options=render_options, @@ -159,89 +215,320 @@ async def test_live_async_create_zugferd_pdf_render_options( _assert_zugferd_output(response.output_file, output_bytes, f"async-{output}") -def test_live_create_zugferd_pdf_regenerates_mismatched_pdf( +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"] - pdf = uploaded_zugferd_resources["pdf"] - logo = uploaded_zugferd_resources["logo"] + footer = "Observable review footer" with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - response = client.create_zugferd_pdf( + 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, - pdf=pdf, + 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, - regenerate_pdf=True, - output="regenerated-zugferd", + render_options={"footer_message": "Replacement permitted"}, + output="replacement-permitted", ) 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") + 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_create_zugferd_pdf_regenerates_mismatched_pdf( +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"] - pdf = uploaded_zugferd_resources["pdf"] + 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: - response = await client.create_zugferd_pdf( - invoice, - pdf=pdf, + 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, - regenerate_pdf=True, - output="async-regenerated-zugferd", + 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, pdf.id, logo.id] + 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-regenerated-zugferd" + response.output_file, + output_bytes, + "async-replacement-permitted", ) -def test_live_create_zugferd_pdf_rejects_invalid_render_option( +@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=r"(?i)locale|invalid"), + pytest.raises(PdfRestApiError, match=error_match), ): - client.create_zugferd_pdf( + client.convert_invoice_xml_to_zugferd_pdf( uploaded_zugferd_resources["invoice"], - extra_body={"render_options": {"locale": "xx-XX"}}, + 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_create_zugferd_pdf_rejects_invalid_render_option( +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=r"(?i)locale|invalid"): - await client.create_zugferd_pdf( + with pytest.raises(PdfRestApiError, match=error_match): + await client.convert_invoice_xml_to_zugferd_pdf( uploaded_zugferd_resources["invoice"], - extra_body={"render_options": {"locale": "xx-XX"}}, + extra_body={"render_options": invalid_options}, ) From c6f23bb789ae26970de32613f6416683dc42dd30 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Mon, 2 Nov 2026 14:25:42 -0600 Subject: [PATCH 7/8] examples: Document ZUGFeRD conversion workflows - Rename the XML generation example: create_zugferd_pdf/ -> convert_invoice_xml_to_zugferd_pdf/ - Add a supplied-PDF example that makes regeneration permission explicit. - Explain preservation and replacement behavior in the API guidance. Assisted-by: Codex --- docs/api-guide.md | 10 ++- docs/llms-full.txt | 5 +- examples/README.md | 7 ++- ...ert_invoice_xml_to_zugferd_pdf_example.py} | 12 ++-- .../convert_pdf_to_zugferd_pdf_example.py | 63 +++++++++++++++++++ 5 files changed, 86 insertions(+), 11 deletions(-) rename examples/{create_zugferd_pdf/create_zugferd_pdf_example.py => convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py} (84%) create mode 100644 examples/convert_pdf_to_zugferd_pdf/convert_pdf_to_zugferd_pdf_example.py diff --git a/docs/api-guide.md b/docs/api-guide.md index be447f2..7a27f8c 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -108,7 +108,12 @@ Use this group for password protection, permissions, signatures, and standards. [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] + [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 @@ -132,7 +137,8 @@ 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_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 56da7ec..d46ac4e 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -159,7 +159,10 @@ to the API guide and API reference for exact method names and options. 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 + 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 5fc6e5a..12a6744 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,8 +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/create_zugferd_pdf/create_zugferd_pdf_example.py` – create a - formatted ZUGFeRD PDF/A-3 invoice from uploaded CII XML. +- `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/create_zugferd_pdf/create_zugferd_pdf_example.py b/examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py similarity index 84% rename from examples/create_zugferd_pdf/create_zugferd_pdf_example.py rename to examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py index 1e9bb3a..6cc262f 100644 --- a/examples/create_zugferd_pdf/create_zugferd_pdf_example.py +++ b/examples/convert_invoice_xml_to_zugferd_pdf/convert_invoice_xml_to_zugferd_pdf_example.py @@ -13,8 +13,8 @@ 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. +``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 @@ -31,8 +31,8 @@ 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.""" +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( @@ -49,7 +49,7 @@ def create_zugferd_pdf() -> None: with PdfRestClient(base_url=base_url) as client: invoice = client.files.create_from_paths(INVOICE_PATH)[0] - response = client.create_zugferd_pdf( + response = client.convert_invoice_xml_to_zugferd_pdf( invoice, render_options=render_options, output="zugferd-invoice", @@ -69,4 +69,4 @@ def create_zugferd_pdf() -> None: if __name__ == "__main__": # pragma: no cover - manual example - create_zugferd_pdf() + 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() From 95ac28ce7b8522ce73a51c648294f29155af6aa3 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Mon, 2 Nov 2026 14:25:56 -0600 Subject: [PATCH 8/8] pyproject: Bump package version to 1.5.0 - Record the new public ZUGFeRD conversion helpers as a minor release. - Keep the locked editable package metadata synchronized. Assisted-by: Codex --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4e489a..d830161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdfrest" -version = "1.4.0" +version = "1.5.0" description = "Python client library for interacting with the pdfRest API" readme = { file = "README.md", content-type = "text/markdown" } authors = [ diff --git a/uv.lock b/uv.lock index a085a9f..ed26310 100644 --- a/uv.lock +++ b/uv.lock @@ -964,7 +964,7 @@ wheels = [ [[package]] name = "pdfrest" -version = "1.4.0" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "exceptiongroup" },