From 9c3ea1201f97ea41174a5977e274d2d5bd4e769e Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 20 Oct 2026 13:45:11 -0500 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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" }, From e43a87e69237408dc1c9fd995f8dcc4a9f9c8fce Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 10 Nov 2026 11:23:52 -0600 Subject: [PATCH 09/14] types: Preserve structured font compatibility - Restore the flat Literal shape used by the structured font catalog. - Keep ZUGFeRD and structured font documentation self-contained. - Add a regression test for runtime alias introspection. Assisted-by: Codex --- src/pdfrest/types/public.py | 199 +++++++++++++++++- ...est_convert_structured_documents_to_pdf.py | 11 +- 2 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index f152311..d777bd8 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -350,9 +350,71 @@ class PdfRedactionInstruction(TypedDict): 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. +These are suggestions, not a closed service catalog. ``PdfZugferdFontName`` +also accepts other installed text-capable 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. """ PdfZugferdFontName: TypeAlias = PdfZugferdKnownFont | str @@ -443,16 +505,137 @@ class PdfZugferdRenderOptions(TypedDict, total=False): - `Tabloid`: Tabloid paper, 792 by 1224 PDF points. """ -PdfStructuredTextKnownFont: TypeAlias = PdfKnownTextFont | Literal["webdings"] +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", +] """Published pdfRest font names for structured document conversion. -This catalog combines every text-capable value documented by -``PdfKnownTextFont`` with the structured-document-only symbol font. -``PdfStructuredTextFontName`` also accepts deployment-specific installed fonts. +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/). Accepted values: -- Every value documented by ``PdfKnownTextFont``: text-capable installed fonts. +- `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. - `webdings`: Webdings. """ diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index 229275f..d59b146 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -2,7 +2,7 @@ import json from collections.abc import Callable -from typing import Any, cast, get_args +from typing import Any, Literal, cast, get_args, get_origin import httpx import pytest @@ -18,7 +18,6 @@ ConvertXmlToPdfPayload, ) from pdfrest.types import ( - PdfKnownTextFont, PdfStructuredTextFontName, PdfStructuredTextKnownFont, PdfStructuredTextPageSetup, @@ -45,7 +44,7 @@ ] KNOWN_FONTS = [ pytest.param(value, id=value.replace(" ", "-")) - for value in (*get_args(PdfKnownTextFont), "webdings") + for value in get_args(PdfStructuredTextKnownFont) ] CSV_DELIMITERS = [ @@ -61,6 +60,12 @@ ] +def test_structured_font_types_preserve_flat_literal_shape() -> None: + assert get_origin(PdfStructuredTextKnownFont) is Literal + assert all(isinstance(value, str) for value in get_args(PdfStructuredTextKnownFont)) + assert get_args(PdfStructuredTextFontName) == (PdfStructuredTextKnownFont, str) + + def _dump_payload(model: BaseModel) -> dict[str, Any]: return model.model_dump( mode="json", by_alias=True, exclude_none=True, exclude_unset=True From b71e03f3ab68ac1f648f402e64567ab184ec4801 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 10 Nov 2026 11:24:10 -0600 Subject: [PATCH 10/14] tests: Complete ZUGFeRD client contract coverage - Pair synchronous and asynchronous checks for render options and bounds. - Assert request routes, serialized payloads, and concrete responses. - Cover open font names and resource cardinality failures. Assisted-by: Codex --- tests/test_create_zugferd_pdf.py | 355 ++++++++++++++++++++++++++++--- 1 file changed, 325 insertions(+), 30 deletions(-) diff --git a/tests/test_create_zugferd_pdf.py b/tests/test_create_zugferd_pdf.py index 9384bee..3365d78 100644 --- a/tests/test_create_zugferd_pdf.py +++ b/tests/test_create_zugferd_pdf.py @@ -75,6 +75,7 @@ def _assert_response( output_id: PdfRestFileID, output_name: str, ) -> None: + assert isinstance(response, PdfRestFileBasedResponse) assert response.input_ids == input_ids assert response.warning is None assert len(response.output_files) == 1 @@ -161,17 +162,17 @@ def test_pdf_payload_maps_regeneration_permission_to_wire_field() -> None: } -@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"), - ], +COLOR_FIELDS = ( + "text_color", + "muted_text_color", + "accent_color", + "border_color", + "table_header_color", + "table_alternate_color", ) + + +@pytest.mark.parametrize("field", COLOR_FIELDS) @pytest.mark.parametrize( ("color", "wire_suffix"), [ @@ -204,6 +205,15 @@ def test_zugferd_payload_accepts_every_published_font( assert payload["render_options"] == {"font": font} +def test_zugferd_payload_accepts_unlisted_installed_font_name() -> None: + invoice = _make_file("invoice.xml", "application/xml") + payload = _dump( + PdfInvoiceXmlToZugferdPayload, + {"invoice": invoice, "render_options": {"font": "Noto Sans"}}, + ) + assert payload["render_options"] == {"font": "Noto Sans"} + + @pytest.mark.parametrize( ("value", "expected"), [ @@ -250,27 +260,64 @@ def test_zugferd_payload_accepts_every_closed_literal(field: str, value: str) -> assert payload["render_options"] == {field: value} -@pytest.mark.parametrize( - "options", - [ - pytest.param({"decimal_precision": 0}, id="precision-minimum"), - pytest.param({"decimal_precision": 4}, id="precision-maximum"), - pytest.param({"text_color": (0, 0, 0)}, id="rgb-minimum"), - pytest.param({"text_color": (255, 255, 255)}, id="rgb-maximum"), - pytest.param({"text_color": (0, 0, 0, 0)}, id="cmyk-minimum"), - pytest.param({"text_color": (100, 100, 100, 100)}, id="cmyk-maximum"), - pytest.param({"font": "x"}, id="font-minimum"), - pytest.param({"font": "x" * 200}, id="font-maximum"), - pytest.param({"footer_message": ""}, id="footer-empty"), - pytest.param({"footer_message": "x" * 500}, id="footer-maximum"), - ], -) -def test_zugferd_payload_accepts_constraint_boundaries(options: dict[str, Any]) -> None: +VALID_RENDER_BOUNDARIES = [ + pytest.param( + {"decimal_precision": 0}, + {"decimal_precision": 0}, + id="precision-minimum", + ), + pytest.param( + {"decimal_precision": 4}, + {"decimal_precision": 4}, + id="precision-maximum", + ), + pytest.param( + {"text_color": (0, 0, 0)}, + {"text_color_rgb": [0, 0, 0]}, + id="rgb-minimum", + ), + pytest.param( + {"text_color": (255, 255, 255)}, + {"text_color_rgb": [255, 255, 255]}, + id="rgb-maximum", + ), + pytest.param( + {"text_color": (0, 0, 0, 0)}, + {"text_color_cmyk": [0, 0, 0, 0]}, + id="cmyk-minimum", + ), + pytest.param( + {"text_color": (100, 100, 100, 100)}, + {"text_color_cmyk": [100, 100, 100, 100]}, + id="cmyk-maximum", + ), + pytest.param({"font": "x"}, {"font": "x"}, id="font-minimum"), + pytest.param({"font": "x" * 200}, {"font": "x" * 200}, id="font-maximum"), + pytest.param({"bold_font": "x"}, {"bold_font": "x"}, id="bold-font-minimum"), + pytest.param( + {"bold_font": "x" * 200}, + {"bold_font": "x" * 200}, + id="bold-font-maximum", + ), + pytest.param({"footer_message": ""}, {"footer_message": ""}, id="footer-empty"), + pytest.param( + {"footer_message": "x" * 500}, + {"footer_message": "x" * 500}, + id="footer-maximum", + ), +] + + +@pytest.mark.parametrize(("options", "expected"), VALID_RENDER_BOUNDARIES) +def test_zugferd_payload_accepts_constraint_boundaries( + options: dict[str, Any], expected: dict[str, Any] +) -> None: invoice = _make_file("invoice.xml", "application/xml") - _dump( + payload = _dump( PdfInvoiceXmlToZugferdPayload, {"invoice": invoice, "render_options": options}, ) + assert payload["render_options"] == expected @pytest.mark.parametrize( @@ -454,6 +501,7 @@ def test_convert_invoice_xml_to_zugferd_pdf_supported_mime_matrix( def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST": counts["post"] += 1 + assert request.url.path == "/zugferd-pdf" assert json.loads(request.content) == { "id": str(invoice.id), "logo_id": str(logo.id), @@ -467,6 +515,8 @@ def handler(request: httpx.Request) -> httpx.Response: "outputId": str(output_id), }, ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" counts["get"] += 1 return httpx.Response( 200, @@ -508,7 +558,12 @@ async def test_async_convert_invoice_xml_to_zugferd_pdf_supported_mime_matrix( async def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST": counts["post"] += 1 - assert json.loads(request.content)["logo_id"] == str(logo.id) + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content) == { + "id": str(invoice.id), + "logo_id": str(logo.id), + "output": "zugferd-async", + } return httpx.Response( 200, json={ @@ -516,6 +571,8 @@ async def handler(request: httpx.Request) -> httpx.Response: "outputId": str(output_id), }, ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" counts["get"] += 1 return httpx.Response( 200, @@ -549,6 +606,7 @@ def test_convert_pdf_to_zugferd_pdf_preserves_pdf_by_default() -> None: def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST": counts["post"] += 1 + assert request.url.path == "/zugferd-pdf" assert json.loads(request.content) == { "id": str(invoice.id), "pdf_id": str(pdf.id), @@ -562,6 +620,8 @@ def handler(request: httpx.Request) -> httpx.Response: "outputId": str(output_id), }, ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" counts["get"] += 1 return httpx.Response( 200, @@ -597,6 +657,7 @@ async def test_async_convert_pdf_to_zugferd_pdf_allows_regeneration_options() -> async def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST": counts["post"] += 1 + assert request.url.path == "/zugferd-pdf" assert json.loads(request.content) == { "id": str(invoice.id), "pdf_id": str(pdf.id), @@ -615,6 +676,8 @@ async def handler(request: httpx.Request) -> httpx.Response: "outputId": str(output_id), }, ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" counts["get"] += 1 return httpx.Response( 200, @@ -651,6 +714,8 @@ def test_convert_invoice_xml_to_zugferd_pdf_accepts_every_published_font( invoice = _make_file("invoice.xml", "application/xml") def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" assert json.loads(request.content)["render_options"] == {"font": font} return httpx.Response(200, json={"inputId": str(invoice.id)}) @@ -670,6 +735,8 @@ async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_every_published_ invoice = _make_file("invoice.xml", "application/xml") async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" assert json.loads(request.content)["render_options"] == {"font": font} return httpx.Response(200, json={"inputId": str(invoice.id)}) @@ -681,6 +748,189 @@ async def handler(request: httpx.Request) -> httpx.Response: ) +def test_convert_invoice_xml_to_zugferd_pdf_accepts_unlisted_font_name() -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {"font": "Noto Sans"} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options={"font": "Noto Sans"} + ) + + +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_unlisted_font_name() -> ( + None +): + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {"font": "Noto Sans"} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options={"font": "Noto Sans"} + ) + + +CLIENT_COLOR_CASES = [ + pytest.param(field, color, suffix, id=f"{field}-{suffix}") + for field in COLOR_FIELDS + for color, suffix in ( + ((0, 127, 255), "rgb"), + ((0, 25, 50, 100), "cmyk"), + ) +] + +INVALID_CLIENT_COLOR_CASES = [ + pytest.param(field, color, error_match, id=f"{field}-{case_id}") + for field in COLOR_FIELDS + for color, error_match, case_id in ( + ((-1, 0, 0), "greater than or equal to 0", "rgb-low"), + ((0, 256, 0), "less than or equal to 255", "rgb-high"), + ((-1, 0, 0, 0), "greater than or equal to 0", "cmyk-low"), + ((0, 101, 0, 0), "less than or equal to 100", "cmyk-high"), + ) +] + + +@pytest.mark.parametrize(("field", "color", "suffix"), CLIENT_COLOR_CASES) +def test_convert_invoice_xml_to_zugferd_pdf_serializes_every_color_option( + field: str, color: tuple[int, ...], suffix: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == { + f"{field}_{suffix}": list(color) + } + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options) + + +@pytest.mark.parametrize(("field", "color", "suffix"), CLIENT_COLOR_CASES) +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_serializes_every_color_option( + field: str, color: tuple[int, ...], suffix: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == { + f"{field}_{suffix}": list(color) + } + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options) + + +@pytest.mark.parametrize(("field", "color", "error_match"), INVALID_CLIENT_COLOR_CASES) +def test_convert_invoice_xml_to_zugferd_pdf_rejects_every_invalid_color_before_transport( + field: str, color: tuple[int, ...], error_match: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("transport must not run for invalid local input") + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match=error_match), + ): + client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options) + + +@pytest.mark.parametrize(("field", "color", "error_match"), INVALID_CLIENT_COLOR_CASES) +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_rejects_every_invalid_color_before_transport( + field: str, color: tuple[int, ...], error_match: str +) -> 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") + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match=error_match): + await client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options=options + ) + + +@pytest.mark.parametrize(("options", "expected"), VALID_RENDER_BOUNDARIES) +def test_convert_invoice_xml_to_zugferd_pdf_accepts_constraint_boundaries( + options: dict[str, Any], expected: dict[str, Any] +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == expected + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + render_options = cast(PdfZugferdRenderOptions, cast(object, options)) + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options=render_options + ) + + +@pytest.mark.parametrize(("options", "expected"), VALID_RENDER_BOUNDARIES) +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_constraint_boundaries( + options: dict[str, Any], expected: dict[str, Any] +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == expected + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + render_options = cast(PdfZugferdRenderOptions, cast(object, options)) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options=render_options + ) + + CLIENT_RENDER_CASES = [ *( pytest.param("logo_corner", value, value, id=f"logo-{value}") @@ -691,9 +941,13 @@ async def handler(request: httpx.Request) -> httpx.Response: pytest.param("currency_display", value, value, id=f"currency-{value}") for value in CURRENCY_DISPLAYS ), + pytest.param("label_language", "en", "en", id="language-english"), pytest.param("label_language", "eng", "en", id="language-english-alias"), + pytest.param("label_language", "de", "de", id="language-german"), pytest.param("label_language", "deu-DE", "de", id="language-german-alias"), + pytest.param("label_language", "fr", "fr", id="language-french"), pytest.param("label_language", "fra", "fr", id="language-french-alias"), + pytest.param("date_format", "dd.MM.yyyy", "dd.MM.yyyy", id="date-format"), ] @@ -704,6 +958,8 @@ def test_convert_invoice_xml_to_zugferd_pdf_accepts_render_contract( invoice = _make_file("invoice.xml", "application/xml") def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" assert json.loads(request.content)["render_options"] == {field: expected} return httpx.Response(200, json={"inputId": str(invoice.id)}) @@ -722,6 +978,8 @@ async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_render_contract( invoice = _make_file("invoice.xml", "application/xml") async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" assert json.loads(request.content)["render_options"] == {field: expected} return httpx.Response(200, json={"inputId": str(invoice.id)}) @@ -739,13 +997,18 @@ async def handler(request: httpx.Request) -> httpx.Response: 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("invalid-corner", "logo_corner", id="invalid-corner"), + pytest.param("invalid-locale", "locale", id="invalid-locale"), + pytest.param("invalid-currency", "currency_display", id="invalid-currency"), 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("bold-font-empty", "at least 1 character", id="bold-font-empty"), + pytest.param("bold-font-long", "at most 200 characters", id="bold-font-long"), pytest.param("footer-long", "at most 500 characters", id="footer-long"), + pytest.param("precision-low", "greater than or equal to 0", id="precision-low"), pytest.param("precision-high", "less than or equal to 4", id="precision-high"), ] @@ -770,7 +1033,12 @@ def _invoke_invalid_generation(client: Any, case: str) -> Any: ] }, ), - "invalid-literal": (invoice, {"render_options": {"locale": "en-US"}}), + "invalid-corner": (invoice, {"render_options": {"logo_corner": "center"}}), + "invalid-locale": (invoice, {"render_options": {"locale": "en-US"}}), + "invalid-currency": ( + invoice, + {"render_options": {"currency_display": "iso"}}, + ), "malformed-color": ( invoice, {"render_options": {"text_color": (-1, 0, 0)}}, @@ -782,10 +1050,19 @@ def _invoke_invalid_generation(client: Any, case: str) -> Any: ), "font-empty": (invoice, {"render_options": {"font": " "}}), "font-long": (invoice, {"render_options": {"font": "x" * 201}}), + "bold-font-empty": (invoice, {"render_options": {"bold_font": " "}}), + "bold-font-long": ( + invoice, + {"render_options": {"bold_font": "x" * 201}}, + ), "footer-long": ( invoice, {"render_options": {"footer_message": "x" * 501}}, ), + "precision-low": ( + invoice, + {"render_options": {"decimal_precision": -1}}, + ), "precision-high": ( invoice, {"render_options": {"decimal_precision": 5}}, @@ -834,6 +1111,8 @@ async def handler(_: httpx.Request) -> httpx.Response: 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( "logo-without-permission", "allow_regeneration=True", @@ -865,6 +1144,18 @@ def _invoke_invalid_pdf_conversion(client: Any, case: str) -> Any: 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 in {"logo-empty", "logo-extra"}: + logos = { + "logo-empty": [], + "logo-extra": [ + _make_file("one.png", "image/png"), + _make_file("two.jpg", "image/jpeg"), + ], + } + kwargs.update( + allow_regeneration=True, + logo=logos[case], + ) elif case == "logo-without-permission": kwargs["logo"] = _make_file("logo.png", "image/png") else: @@ -925,6 +1216,8 @@ def test_zugferd_sync_request_customization( pdf = _make_file("source.pdf", "application/pdf") def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" assert request.url.params["trace"] == "sync" assert request.headers["X-Trace"] == "zugferd" _assert_timeout(request.extensions.get("timeout"), 14.0) @@ -965,6 +1258,8 @@ async def test_zugferd_async_request_customization(method_name: str) -> None: pdf = _make_file("source.pdf", "application/pdf") async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" assert request.url.params["trace"] == "async" assert request.headers["X-Trace"] == "zugferd-async" _assert_timeout(request.extensions.get("timeout"), 21.0) From b0dbebf048e28393cb18f2d42c2b042ccb6c9891 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 10 Nov 2026 11:26:53 -0600 Subject: [PATCH 11/14] tests: Prove ZUGFeRD live rendering behavior - Derive live literal catalogs from the public SDK types. - Verify requested fonts, isolated visual controls, and replacement output. - Add a licensed two-item invoice fixture for alternate-row evidence. Assisted-by: Codex --- tests/live/test_live_create_zugferd_pdf.py | 292 ++++++++++++++++---- tests/resources/zugferd-basic-two-items.xml | 234 ++++++++++++++++ 2 files changed, 474 insertions(+), 52 deletions(-) create mode 100644 tests/resources/zugferd-basic-two-items.xml diff --git a/tests/live/test_live_create_zugferd_pdf.py b/tests/live/test_live_create_zugferd_pdf.py index c5c1069..ce89866 100644 --- a/tests/live/test_live_create_zugferd_pdf.py +++ b/tests/live/test_live_create_zugferd_pdf.py @@ -1,23 +1,40 @@ from __future__ import annotations -from typing import TypedDict +from typing import Literal, TypedDict, cast, get_args import pytest from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient -from pdfrest.models import PdfRestFile -from pdfrest.types import PdfZugferdRenderOptions +from pdfrest.models import ExtractedTextDocument, PdfRestFile +from pdfrest.types import ( + PdfZugferdCurrencyDisplay, + PdfZugferdKnownFont, + PdfZugferdLabelLanguage, + PdfZugferdLocale, + PdfZugferdLogoCorner, + PdfZugferdRenderOptions, +) from ..resources import get_test_resource_path class ZugferdResources(TypedDict): invoice: PdfRestFile + multi_item_invoice: PdfRestFile logo: PdfRestFile mismatched_pdf: PdfRestFile matching_pdf: PdfRestFile +LOGO_CORNERS = cast(tuple[PdfZugferdLogoCorner, ...], get_args(PdfZugferdLogoCorner)) +LOCALES = cast(tuple[PdfZugferdLocale, ...], get_args(PdfZugferdLocale)) +CURRENCY_DISPLAYS = cast( + tuple[PdfZugferdCurrencyDisplay, ...], get_args(PdfZugferdCurrencyDisplay) +) +LABEL_LANGUAGES = cast(tuple[str, ...], get_args(get_args(PdfZugferdLabelLanguage)[0])) +KNOWN_FONTS = cast(tuple[PdfZugferdKnownFont, ...], get_args(PdfZugferdKnownFont)) + + ISOLATED_RENDER_OPTION_CASES = [ *( pytest.param( @@ -25,7 +42,7 @@ class ZugferdResources(TypedDict): f"logo-{value}", id=f"logo-{value}", ) - for value in ("top-left", "top-right", "bottom-left", "bottom-right") + for value in LOGO_CORNERS ), *( pytest.param( @@ -33,7 +50,7 @@ class ZugferdResources(TypedDict): f"locale-{value}", id=f"locale-{value}", ) - for value in ("de-DE", "fr-FR", "nl-NL", "en-GB") + for value in LOCALES ), *( pytest.param( @@ -41,7 +58,7 @@ class ZugferdResources(TypedDict): f"language-{value}", id=f"language-{value}", ) - for value in ("en", "de", "fr") + for value in LABEL_LANGUAGES ), *( pytest.param( @@ -49,7 +66,7 @@ class ZugferdResources(TypedDict): f"currency-{value}", id=f"currency-{value}", ) - for value in ("code", "symbol", "name") + for value in CURRENCY_DISPLAYS ), pytest.param( PdfZugferdRenderOptions(decimal_precision=0), @@ -68,6 +85,7 @@ class ZugferdResources(TypedDict): {"logo_corner": "center"}, r"(?i)logo.corner|invalid", id="logo-corner" ), pytest.param({"currency_display": "iso"}, r"(?i)currency|invalid", id="currency"), + pytest.param({"locale": "en-US"}, r"(?i)locale|invalid", id="locale"), pytest.param( {"decimal_precision": -1}, r"(?i)decimal|precision|range|invalid", @@ -100,6 +118,7 @@ def uploaded_zugferd_resources( ) -> ZugferdResources: paths = [ get_test_resource_path("zugferd-minimum-invoice.xml"), + get_test_resource_path("zugferd-basic-two-items.xml"), get_test_resource_path("signing_logo.png"), get_test_resource_path("report.pdf"), ] @@ -107,13 +126,16 @@ def uploaded_zugferd_resources( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - invoice, logo, mismatched_pdf = client.files.create_from_paths(paths) + invoice, multi_item_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, + multi_item_invoice=multi_item_invoice, logo=logo, mismatched_pdf=mismatched_pdf, matching_pdf=matching_pdf, @@ -160,6 +182,59 @@ async def _render_first_page_async( return await client.files.read_bytes(response.output_files[0]) +def _font_names(document: ExtractedTextDocument) -> set[str]: + assert document.words is not None + names = {word.style.font.name for word in document.words if word.style is not None} + assert names + return names + + +FONT_RENDER_OPTION_CASES = [ + pytest.param( + PdfZugferdRenderOptions(font="courier"), + ("courier",), + "known-body-font", + id="known-body-font", + ), + pytest.param( + PdfZugferdRenderOptions(font="Noto Sans"), + ("noto", "sans"), + "installed-body-font", + id="installed-body-font", + ), + pytest.param( + PdfZugferdRenderOptions(bold_font="courierbold"), + ("courier", "bold"), + "bold-font", + id="bold-font", + ), +] + +OBSERVABLE_VISUAL_CASES = [ + pytest.param( + PdfZugferdRenderOptions(border_color=(255, 0, 0)), + "border-color", + None, + "invoice", + id="border-color", + ), + pytest.param( + PdfZugferdRenderOptions(table_alternate_color=(255, 255, 0)), + "alternate-row-color", + None, + "multi_item_invoice", + id="alternate-row-color", + ), + pytest.param( + PdfZugferdRenderOptions(footer_message="Observable review footer"), + "footer-message", + "Observable review footer", + "invoice", + id="footer-message", + ), +] + + @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, @@ -215,104 +290,189 @@ async def test_live_async_convert_invoice_xml_to_zugferd_pdf_isolated_render_opt _assert_zugferd_output(response.output_file, output_bytes, f"async-{output}") -def test_live_convert_invoice_xml_to_zugferd_pdf_observable_render_controls( +@pytest.mark.parametrize( + ("render_options", "expected_font_fragments", "output"), + FONT_RENDER_OPTION_CASES, +) +def test_live_convert_invoice_xml_to_zugferd_pdf_uses_requested_font_resource( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + expected_font_fragments: tuple[str, ...], + output: str, ) -> None: + assert "courier" in KNOWN_FONTS + assert "Noto Sans" not in KNOWN_FONTS invoice = uploaded_zugferd_resources["invoice"] - footer = "Observable review footer" with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - baseline = client.convert_invoice_xml_to_zugferd_pdf( - invoice, - output="baseline-render-controls", - ) response = client.convert_invoice_xml_to_zugferd_pdf( invoice, - render_options=PdfZugferdRenderOptions( - font="courier", - bold_font="courierbold", - border_color=(0, 100, 100, 0), - table_alternate_color=(255, 255, 0), - footer_message=footer, - ), - output="observable-render-controls", + render_options=render_options, + output=output, ) 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", + extracted = client.extract_pdf_text( + response.output_file, + word_style=True, ) - customized_png = _render_first_page( - client, + + names = {name.lower() for name in _font_names(extracted)} + assert any( + all(fragment in name for fragment in expected_font_fragments) for name in names + ) + assert response.warning is None + _assert_zugferd_output(response.output_file, output_bytes, output) + + +@pytest.mark.parametrize( + ("render_options", "expected_font_fragments", "output"), + FONT_RENDER_OPTION_CASES, +) +@pytest.mark.asyncio +async def test_live_async_convert_invoice_xml_to_zugferd_pdf_uses_requested_font_resource( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + expected_font_fragments: tuple[str, ...], + output: str, +) -> None: + assert "courier" in KNOWN_FONTS + assert "Noto Sans" not in KNOWN_FONTS + invoice = uploaded_zugferd_resources["invoice"] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_invoice_xml_to_zugferd_pdf( + invoice, + render_options=render_options, + output=f"async-{output}", + ) + output_bytes = await client.files.read_bytes(response.output_file) + extracted = await client.extract_pdf_text( response.output_file, - "observable-render-controls", + word_style=True, ) - assert customized_png != baseline_png - assert extracted.full_text is not None - assert footer in (extracted.full_text.document_text or "") + names = {name.lower() for name in _font_names(extracted)} + assert any( + all(fragment in name for fragment in expected_font_fragments) for name in names + ) assert response.warning is None _assert_zugferd_output( response.output_file, output_bytes, - "observable-render-controls", + f"async-{output}", ) +@pytest.mark.parametrize( + ("render_options", "output", "expected_text", "invoice_key"), + OBSERVABLE_VISUAL_CASES, +) +def test_live_convert_invoice_xml_to_zugferd_pdf_observes_each_visual_control( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + output: str, + expected_text: str | None, + invoice_key: Literal["invoice", "multi_item_invoice"], +) -> None: + invoice = uploaded_zugferd_resources[invoice_key] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + baseline = client.convert_invoice_xml_to_zugferd_pdf( + invoice, + output=f"baseline-{output}", + ) + response = client.convert_invoice_xml_to_zugferd_pdf( + invoice, + render_options=render_options, + output=output, + ) + output_bytes = client.files.read_bytes(response.output_file) + baseline_png = _render_first_page( + client, baseline.output_file, f"base-{output}" + ) + customized_png = _render_first_page(client, response.output_file, output) + extracted = ( + client.extract_pdf_text(response.output_file) + if expected_text is not None + else None + ) + + assert customized_png != baseline_png + if expected_text is not None: + assert extracted is not None + assert extracted.full_text is not None + assert expected_text in (extracted.full_text.document_text or "") + assert response.warning is None + _assert_zugferd_output(response.output_file, output_bytes, output) + + +@pytest.mark.parametrize( + ("render_options", "output", "expected_text", "invoice_key"), + OBSERVABLE_VISUAL_CASES, +) @pytest.mark.asyncio -async def test_live_async_convert_invoice_xml_to_zugferd_pdf_observable_render_controls( +async def test_live_async_convert_invoice_xml_to_zugferd_pdf_observes_each_visual_control( pdfrest_api_key: str, pdfrest_live_base_url: str, uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + output: str, + expected_text: str | None, + invoice_key: Literal["invoice", "multi_item_invoice"], ) -> None: - invoice = uploaded_zugferd_resources["invoice"] - footer = "Observable async review footer" + invoice = uploaded_zugferd_resources[invoice_key] 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", + output=f"async-baseline-{output}", ) response = await client.convert_invoice_xml_to_zugferd_pdf( invoice, - render_options=PdfZugferdRenderOptions( - font="courier", - bold_font="courierbold", - border_color=(0, 100, 100, 0), - table_alternate_color=(255, 255, 0), - footer_message=footer, - ), - output="async-observable-render-controls", + render_options=render_options, + output=f"async-{output}", ) 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", + f"async-base-{output}", ) customized_png = await _render_first_page_async( client, response.output_file, - "async-observable-render-controls", + f"async-{output}", + ) + extracted = ( + await client.extract_pdf_text(response.output_file) + if expected_text is not None + else None ) assert customized_png != baseline_png - assert extracted.full_text is not None - assert footer in (extracted.full_text.document_text or "") + if expected_text is not None: + assert extracted is not None + assert extracted.full_text is not None + assert expected_text in (extracted.full_text.document_text or "") assert response.warning is None _assert_zugferd_output( response.output_file, output_bytes, - "async-observable-render-controls", + f"async-{output}", ) @@ -439,6 +599,11 @@ def test_live_convert_pdf_to_zugferd_pdf_allows_replacement( base_url=pdfrest_live_base_url, ) as client: source_bytes = client.files.read_bytes(mismatched_pdf) + source_png = _render_first_page( + client, + mismatched_pdf, + "source-before-replacement", + ) response = client.convert_pdf_to_zugferd_pdf( mismatched_pdf, invoice=invoice, @@ -448,10 +613,19 @@ def test_live_convert_pdf_to_zugferd_pdf_allows_replacement( output="replacement-permitted", ) output_bytes = client.files.read_bytes(response.output_file) + output_png = _render_first_page( + client, + response.output_file, + "replacement-permitted", + ) + extracted = client.extract_pdf_text(response.output_file) assert response.input_ids == [invoice.id, mismatched_pdf.id, logo.id] assert response.warning is None assert output_bytes != source_bytes + assert output_png != source_png + assert extracted.full_text is not None + assert "Replacement permitted" in (extracted.full_text.document_text or "") _assert_zugferd_output(response.output_file, output_bytes, "replacement-permitted") @@ -469,6 +643,11 @@ async def test_live_async_convert_pdf_to_zugferd_pdf_allows_replacement( base_url=pdfrest_live_base_url, ) as client: source_bytes = await client.files.read_bytes(mismatched_pdf) + source_png = await _render_first_page_async( + client, + mismatched_pdf, + "async-source-before-replacement", + ) response = await client.convert_pdf_to_zugferd_pdf( mismatched_pdf, invoice=invoice, @@ -478,10 +657,19 @@ async def test_live_async_convert_pdf_to_zugferd_pdf_allows_replacement( output="async-replacement-permitted", ) output_bytes = await client.files.read_bytes(response.output_file) + output_png = await _render_first_page_async( + client, + response.output_file, + "async-replacement-permitted", + ) + extracted = await client.extract_pdf_text(response.output_file) assert response.input_ids == [invoice.id, mismatched_pdf.id, logo.id] assert response.warning is None assert output_bytes != source_bytes + assert output_png != source_png + assert extracted.full_text is not None + assert "Async replacement permitted" in (extracted.full_text.document_text or "") _assert_zugferd_output( response.output_file, output_bytes, diff --git a/tests/resources/zugferd-basic-two-items.xml b/tests/resources/zugferd-basic-two-items.xml new file mode 100644 index 0000000..2ca8180 --- /dev/null +++ b/tests/resources/zugferd-basic-two-items.xml @@ -0,0 +1,234 @@ + + + + + + + + urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic + + + + 471102 + 380 + + 20260305 + + + Rechnung gemäß Bestellung vom 01.03.2026. + + + Lieferant GmbH +Lieferantenstraße 20 +80333 München +Deutschland +Geschäftsführer: Hans Muster +Handelsregisternummer: H A 123 + + REG + + + + + + 1 + + + 4012345001235 + Unsere Art.-Nr.: TB100A4 Trennblätter A4 + + + + 9.90 + + + + 20.0000 + + + + VAT + S + 19 + + + 198.00 + + + + + + 2 + + + 4012345001242 + Unsere Art.-Nr.: ST200A4 Schreibunterlage A4 + + + + 12.50 + + + + 4.0000 + + + + VAT + S + 19 + + + 50.00 + + + + + + 4000001123452 + Lieferant GmbH + + 80333 + Lieferantenstraße 20 + München + DE + + + 201/113/40209 + + + DE123456789 + + + + GE2020211 + 4000001987658 + Kunden AG Mitte + + 69876 + Hans Muster + Kundenstraße 15 + Frankfurt + DE + + + + + + + 20260305 + + + + + EUR + + 47.12 + VAT + 248.00 + S + 19.00 + + + Zahlbar innerhalb 30 Tagen netto bis 04.04.2026, 3% Skonto innerhalb 10 Tagen bis 15.03.2026. + + 20260404 + + + + 248.00 + 0.00 + 0.00 + 248.00 + 47.12 + 295.12 + 295.12 + + + + From b6fac143a8969fd2e6387a27f0b31c54b9d22a23 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 10 Nov 2026 11:27:10 -0600 Subject: [PATCH 12/14] pdfrest-client-api: Require public type compatibility - Extend compatibility checks to runtime and static type contracts. - Require unchanged unrelated tests and self-contained literal docs. - Add explicit base comparisons and rendered-reference inspection. Assisted-by: Codex --- .agents/skills/pdfrest-client-api/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.agents/skills/pdfrest-client-api/SKILL.md b/.agents/skills/pdfrest-client-api/SKILL.md index 115f856..0dd8f65 100644 --- a/.agents/skills/pdfrest-client-api/SKILL.md +++ b/.agents/skills/pdfrest-client-api/SKILL.md @@ -147,6 +147,15 @@ body to the current API contract. - Do not change positional meaning, narrow accepted types, alter established defaults, remove arguments, or change response shapes as part of an API adaptation. +- Treat every existing public export as part of the compatibility surface, not + only client methods. Preserve public type aliases at runtime and under static + type checking: equality, ``typing.get_origin()``, ``typing.get_args()``, + annotations, and assignability must remain equivalent to the base branch. + Reusing or composing a public type is acceptable only when those observable + contracts remain unchanged. +- Run the existing tests for unrelated services unchanged. If a new endpoint + makes one fail, fix the compatibility break instead of rewriting that test to + accept the regression. Add focused tests for the new behavior separately. - If the server requires a breaking SDK change and no faithful compatible translation/default exists, stop and ask the user for a migration decision before editing. @@ -385,6 +394,10 @@ documentation with its types; do not hand-copy a shape schema into Markdown. placement and an `Accepted values:` list whose bullets document each literal spelling and meaning. Inspect generated HTML to confirm the list renders with the alias rather than only in the source file. +- Make every public literal's rendered documentation self-contained. When an + alias reuses or extends another literal, expand the complete applicable list + at that alias instead of directing readers to combine values from another + entry. - Public types are re-exported through `pdfrest.types`. Verify that the rendered reference resolves the re-export to its source union and member types. A self-reference such as `PdfAddShapeObject = PdfAddShapeObject` is a rendering @@ -404,3 +417,7 @@ Run the full pytest suite and `uvx nox -s tests` when practical. For API reference changes, also run the strict docs build. Run the focused example and the example matrix as described above. Report the OpenAPI operation inspected, files changed, checks run, checks skipped, and any live-validation limitation. +Before completion, compare every touched pre-existing public interface with the +base branch at runtime and with the type checker, run its original unrelated +tests without adaptations, and inspect rendered HTML for complete literal value +lists rather than relying on a successful documentation build alone. From f6689bc42022d52b773a688a7f9c703d95a35039 Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Tue, 10 Nov 2026 11:32:37 -0600 Subject: [PATCH 13/14] pdfrest-client-api: Apply Markdown code formatting Assisted-by: Codex --- .agents/skills/pdfrest-client-api/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/pdfrest-client-api/SKILL.md b/.agents/skills/pdfrest-client-api/SKILL.md index 0dd8f65..dc8d90c 100644 --- a/.agents/skills/pdfrest-client-api/SKILL.md +++ b/.agents/skills/pdfrest-client-api/SKILL.md @@ -149,7 +149,7 @@ body to the current API contract. adaptation. - Treat every existing public export as part of the compatibility surface, not only client methods. Preserve public type aliases at runtime and under static - type checking: equality, ``typing.get_origin()``, ``typing.get_args()``, + type checking: equality, `typing.get_origin()`, `typing.get_args()`, annotations, and assignability must remain equivalent to the base branch. Reusing or composing a public type is acceptable only when those observable contracts remain unchanged. From f8efb8fabd942cabafd33ce3a1ee037c75fec63f Mon Sep 17 00:00:00 2001 From: Taylor Smith Date: Wed, 11 Nov 2026 10:32:11 -0600 Subject: [PATCH 14/14] tests: Complete ZUGFeRD coverage parity - Pair preservation and regeneration success paths across both clients. - Exercise precision boundary interiors through payload and client tests. - Verify remaining render controls and language rejection with live calls. Assisted-by: Codex --- tests/live/test_live_create_zugferd_pdf.py | 70 ++++++++++-- tests/test_create_zugferd_pdf.py | 120 +++++++++++++++++++++ 2 files changed, 181 insertions(+), 9 deletions(-) diff --git a/tests/live/test_live_create_zugferd_pdf.py b/tests/live/test_live_create_zugferd_pdf.py index ce89866..1e5cba4 100644 --- a/tests/live/test_live_create_zugferd_pdf.py +++ b/tests/live/test_live_create_zugferd_pdf.py @@ -3,9 +3,11 @@ from typing import Literal, TypedDict, cast, get_args import pytest +from annotated_types import Ge, Le from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import ExtractedTextDocument, PdfRestFile +from pdfrest.models._internal import _PdfZugferdRenderOptions from pdfrest.types import ( PdfZugferdCurrencyDisplay, PdfZugferdKnownFont, @@ -35,6 +37,18 @@ class ZugferdResources(TypedDict): KNOWN_FONTS = cast(tuple[PdfZugferdKnownFont, ...], get_args(PdfZugferdKnownFont)) +def _decimal_precision_values() -> tuple[int, int, int, int]: + field = _PdfZugferdRenderOptions.model_fields["decimal_precision"] + minimum = next(item.ge for item in field.metadata if isinstance(item, Ge)) + maximum = next(item.le for item in field.metadata if isinstance(item, Le)) + assert isinstance(minimum, int) + assert isinstance(maximum, int) + return minimum, minimum + 1, maximum - 1, maximum + + +DECIMAL_PRECISION_VALUES = _decimal_precision_values() + + ISOLATED_RENDER_OPTION_CASES = [ *( pytest.param( @@ -68,15 +82,13 @@ class ZugferdResources(TypedDict): ) for value in CURRENCY_DISPLAYS ), - pytest.param( - PdfZugferdRenderOptions(decimal_precision=0), - "precision-minimum", - id="precision-minimum", - ), - pytest.param( - PdfZugferdRenderOptions(decimal_precision=4), - "precision-maximum", - id="precision-maximum", + *( + pytest.param( + PdfZugferdRenderOptions(decimal_precision=value), + f"precision-{value}", + id=f"precision-{value}", + ) + for value in DECIMAL_PRECISION_VALUES ), ] @@ -86,6 +98,11 @@ class ZugferdResources(TypedDict): ), pytest.param({"currency_display": "iso"}, r"(?i)currency|invalid", id="currency"), pytest.param({"locale": "en-US"}, r"(?i)locale|invalid", id="locale"), + pytest.param( + {"label_language": "nl"}, + r"(?i)label.language|English|German|French|invalid", + id="label-language", + ), pytest.param( {"decimal_precision": -1}, r"(?i)decimal|precision|range|invalid", @@ -211,6 +228,27 @@ def _font_names(document: ExtractedTextDocument) -> set[str]: ] OBSERVABLE_VISUAL_CASES = [ + pytest.param( + PdfZugferdRenderOptions(text_color=(255, 0, 0)), + "text-color", + None, + "invoice", + id="text-color", + ), + pytest.param( + PdfZugferdRenderOptions(muted_text_color=(255, 0, 0)), + "muted-text-color", + None, + "invoice", + id="muted-text-color", + ), + pytest.param( + PdfZugferdRenderOptions(accent_color=(255, 0, 0)), + "accent-color", + None, + "invoice", + id="accent-color", + ), pytest.param( PdfZugferdRenderOptions(border_color=(255, 0, 0)), "border-color", @@ -218,6 +256,13 @@ def _font_names(document: ExtractedTextDocument) -> set[str]: "invoice", id="border-color", ), + pytest.param( + PdfZugferdRenderOptions(table_header_color=(255, 0, 0)), + "table-header-color", + None, + "multi_item_invoice", + id="table-header-color", + ), pytest.param( PdfZugferdRenderOptions(table_alternate_color=(255, 255, 0)), "alternate-row-color", @@ -232,6 +277,13 @@ def _font_names(document: ExtractedTextDocument) -> set[str]: "invoice", id="footer-message", ), + pytest.param( + PdfZugferdRenderOptions(date_format="yyyy/MM/dd"), + "date-format", + "2020/03/05", + "invoice", + id="date-format", + ), ] diff --git a/tests/test_create_zugferd_pdf.py b/tests/test_create_zugferd_pdf.py index 3365d78..ff8f0d6 100644 --- a/tests/test_create_zugferd_pdf.py +++ b/tests/test_create_zugferd_pdf.py @@ -266,6 +266,16 @@ def test_zugferd_payload_accepts_every_closed_literal(field: str, value: str) -> {"decimal_precision": 0}, id="precision-minimum", ), + pytest.param( + {"decimal_precision": 1}, + {"decimal_precision": 1}, + id="precision-after-minimum", + ), + pytest.param( + {"decimal_precision": 3}, + {"decimal_precision": 3}, + id="precision-before-maximum", + ), pytest.param( {"decimal_precision": 4}, {"decimal_precision": 4}, @@ -646,6 +656,116 @@ def handler(request: httpx.Request) -> httpx.Response: ) +@pytest.mark.asyncio +async def test_async_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} + + async def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + counts["post"] += 1 + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content) == { + "id": str(invoice.id), + "pdf_id": str(pdf.id), + "regenerate_pdf": False, + "output": "async-preserved", + } + return httpx.Response( + 200, + json={ + "inputId": [str(invoice.id), str(pdf.id)], + "outputId": str(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( + str(output_id), "async-preserved.pdf", "application/pdf" + ), + ) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = await client.convert_pdf_to_zugferd_pdf( + pdf, invoice=invoice, output="async-preserved" + ) + + assert counts == {"post": 1, "get": 1} + _assert_response( + response, + input_ids=[invoice.id, pdf.id], + output_id=output_id, + output_name="async-preserved.pdf", + ) + + +def test_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} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + counts["post"] += 1 + assert request.url.path == "/zugferd-pdf" + 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), + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + counts["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "replacement-allowed.pdf", "application/pdf" + ), + ) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = client.convert_pdf_to_zugferd_pdf( + pdf, + invoice=invoice, + allow_regeneration=True, + logo=logo, + render_options={"bold_font": "arialbold", "border_color": (0, 0, 0, 50)}, + output="replacement-allowed", + ) + + assert counts == {"post": 1, "get": 1} + _assert_response( + response, + input_ids=[invoice.id, pdf.id, logo.id], + output_id=output_id, + output_name="replacement-allowed.pdf", + ) + + @pytest.mark.asyncio async def test_async_convert_pdf_to_zugferd_pdf_allows_regeneration_options() -> None: invoice = _make_file("invoice.xml", "text/xml", prefix=1)