diff --git a/docs/api-guide.md b/docs/api-guide.md index b233d88..be447f2 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -107,6 +107,8 @@ Use this group for password protection, permissions, signatures, and standards. - Archival/print conformance: [convert_to_pdfa][pdfrest.PdfRestClient.convert_to_pdfa], [convert_to_pdfx][pdfrest.PdfRestClient.convert_to_pdfx] +- Electronic invoices: + [create_zugferd_pdf][pdfrest.PdfRestClient.create_zugferd_pdf] ## PDF cleanup and rendering normalization @@ -130,6 +132,7 @@ Use this group when the user starts with non-PDF content or needs downstream formats. - Into PDF: + [create_zugferd_pdf][pdfrest.PdfRestClient.create_zugferd_pdf], [convert_office_to_pdf][pdfrest.PdfRestClient.convert_office_to_pdf], [convert_postscript_to_pdf][pdfrest.PdfRestClient.convert_postscript_to_pdf], [convert_email_to_pdf][pdfrest.PdfRestClient.convert_email_to_pdf], diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 5b55afd..56da7ec 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -158,6 +158,8 @@ to the API guide and API reference for exact method names and options. - Apply an uploaded `.joboptions` Distiller profile when converting PostScript or EPS with `convert_postscript_to_pdf` - Convert to standards formats (`PDF/A`, `PDF/X`) +- Create validated ZUGFeRD or Factur-X PDF/A-3 invoices from uploaded CII XML + with optional source-PDF, logo, and render controls ### Extraction and Analysis diff --git a/examples/README.md b/examples/README.md index 39bf5ee..5fc6e5a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,6 +35,8 @@ supported interpreter matrix. synchronously convert selected PDF pages and verify the PostScript output. - `examples/convert_pdf_to_postscript/async_convert_pdf_to_postscript_example.py` – asynchronously convert a PDF page and verify the PostScript output. +- `examples/create_zugferd_pdf/create_zugferd_pdf_example.py` – create a + formatted ZUGFeRD PDF/A-3 invoice from uploaded CII XML. - `examples/delete/delete_example.py` – demonstrate file deletion (sync + async variants). - `examples/extract_text/extract_pdf_text_example.py` – run `extract_pdf_text` diff --git a/examples/create_zugferd_pdf/create_zugferd_pdf_example.py b/examples/create_zugferd_pdf/create_zugferd_pdf_example.py new file mode 100644 index 0000000..1e9bb3a --- /dev/null +++ b/examples/create_zugferd_pdf/create_zugferd_pdf_example.py @@ -0,0 +1,72 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Create a branded ZUGFeRD PDF/A-3 invoice from CII XML. + +This sample demonstrates how to: + +1. Upload the checked-in invoice XML resource. +2. Generate a ZUGFeRD PDF with typed appearance and locale options. +3. Download and verify the generated PDF. +4. Print the result metadata and input resource IDs. + +Set ``PDFREST_API_KEY`` and optionally ``PDFREST_BASE_URL`` for a self-hosted +deployment, then run from the repository root with +``uv run examples/create_zugferd_pdf/create_zugferd_pdf_example.py``. The +required XML input is included in the repository. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient +from pdfrest.types import PdfZugferdRenderOptions + +RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources" +INVOICE_PATH = RESOURCE_DIRECTORY / "zugferd-minimum-invoice.xml" + + +def create_zugferd_pdf() -> None: + """Upload invoice resources, create the PDF/A-3 invoice, and verify it.""" + load_dotenv() + base_url = os.getenv("PDFREST_BASE_URL") + render_options = PdfZugferdRenderOptions( + font="courier", + bold_font="courierbold", + accent_color=(20, 64, 115), + locale="de-DE", + label_language="en", + currency_display="symbol", + date_format="dd.MM.yyyy", + decimal_precision=2, + footer_message="Thank you for your business.", + ) + + with PdfRestClient(base_url=base_url) as client: + invoice = client.files.create_from_paths(INVOICE_PATH)[0] + response = client.create_zugferd_pdf( + invoice, + render_options=render_options, + output="zugferd-invoice", + ) + pdf_bytes = client.files.read_bytes(response.output_file) + + if not pdf_bytes.startswith(b"%PDF-"): + msg = "The generated resource is not a PDF." + raise RuntimeError(msg) + + output = response.output_file + print(f"Created: {output.name}") + print(f"MIME type: {output.type}") + print(f"Size: {output.size} bytes") + print(f"Input IDs: {', '.join(response.input_ids)}") + print(f"Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + create_zugferd_pdf() diff --git a/examples/resources/zugferd-minimum-invoice.xml b/examples/resources/zugferd-minimum-invoice.xml new file mode 100644 index 0000000..9fd3a95 --- /dev/null +++ b/examples/resources/zugferd-minimum-invoice.xml @@ -0,0 +1,51 @@ + + + + + + urn:factur-x.eu:1p0:minimum + + + + 471102 + 380 + + 20200305 + + + + + + Lieferant GmbH + + DE + + + 201/113/40209 + + + DE123456789 + + + + Kunden AG Frankreich + + + + + EUR + + 198.00 + 37.62 + 235.62 + 235.62 + + + + diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 7849669..94878ae 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -129,6 +129,7 @@ PdfToWordPayload, PdfUnrestrictPayload, PdfXfaToAcroformsPayload, + PdfZugferdPayload, PngPdfRestPayload, SummarizePdfTextPayload, TiffPdfRestPayload, @@ -178,6 +179,7 @@ PdfStructuredTextTableStyle, PdfTextColor, PdfXType, + PdfZugferdRenderOptions, PngColorModel, PostScriptLevel, SummaryFormat, @@ -4615,6 +4617,69 @@ def sign_pdf( timeout=timeout, ) + def create_zugferd_pdf( + self, + invoice: PdfRestFile | Sequence[PdfRestFile], + *, + pdf: PdfRestFile | Sequence[PdfRestFile] | None = None, + logo: PdfRestFile | Sequence[PdfRestFile] | None = None, + render_options: PdfZugferdRenderOptions | None = None, + regenerate_pdf: bool = False, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Create a validated ZUGFeRD or Factur-X PDF/A-3 invoice. + + Args: + invoice: Uploaded CII invoice XML resource. + pdf: Optional uploaded PDF to preserve when its content matches the + invoice XML. + logo: Optional uploaded PNG or JPEG logo for generated or + regenerated visual PDFs. + render_options: Optional appearance and regional-formatting options. + regenerate_pdf: Allow replacement of the supplied PDF when its + content cannot be confirmed against the invoice XML. Requires + ``pdf`` when true. + output: Output filename prefix used by pdfRest when creating the + ZUGFeRD PDF. + extra_query: Additional query parameters merged into the request. + extra_headers: Additional HTTP headers merged into the request. + extra_body: Additional request body fields merged into the payload. + timeout: Request timeout override for this call. + + Returns: + Validated file-based response containing the generated PDF/A-3 file. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "invoice": invoice, + "regenerate_pdf": regenerate_pdf, + } + if pdf is not None: + payload["pdf"] = pdf + if logo is not None: + payload["logo"] = logo + if render_options is not None: + payload["render_options"] = render_options + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/zugferd-pdf", + payload=payload, + payload_model=PdfZugferdPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def blank_pdf( self, *, @@ -8151,6 +8216,72 @@ async def sign_pdf( timeout=timeout, ) + async def create_zugferd_pdf( + self, + invoice: PdfRestFile | Sequence[PdfRestFile], + *, + pdf: PdfRestFile | Sequence[PdfRestFile] | None = None, + logo: PdfRestFile | Sequence[PdfRestFile] | None = None, + render_options: PdfZugferdRenderOptions | None = None, + regenerate_pdf: bool = False, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously create a ZUGFeRD or Factur-X PDF/A-3 invoice. + + This is the asynchronous variant of + [PdfRestClient.create_zugferd_pdf][pdfrest.PdfRestClient.create_zugferd_pdf]. + + Args: + invoice: Uploaded CII invoice XML resource. + pdf: Optional uploaded PDF to preserve when its content matches the + invoice XML. + logo: Optional uploaded PNG or JPEG logo for generated or + regenerated visual PDFs. + render_options: Optional appearance and regional-formatting options. + regenerate_pdf: Allow replacement of the supplied PDF when its + content cannot be confirmed against the invoice XML. Requires + ``pdf`` when true. + output: Output filename prefix used by pdfRest when creating the + ZUGFeRD PDF. + extra_query: Additional query parameters merged into the request. + extra_headers: Additional HTTP headers merged into the request. + extra_body: Additional request body fields merged into the payload. + timeout: Request timeout override for this call. + + Returns: + Validated file-based response containing the generated PDF/A-3 file. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "invoice": invoice, + "regenerate_pdf": regenerate_pdf, + } + if pdf is not None: + payload["pdf"] = pdf + if logo is not None: + payload["logo"] = logo + if render_options is not None: + payload["render_options"] = render_options + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/zugferd-pdf", + payload=payload, + payload_model=PdfZugferdPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def blank_pdf( self, *, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index e32014a..21173a6 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -49,6 +49,11 @@ PdfStructuredTextPageSize, PdfStructuredTextTextAlignment, PdfXType, + PdfZugferdCurrencyDisplay, + PdfZugferdFontName, + PdfZugferdLabelLanguage, + PdfZugferdLocale, + PdfZugferdLogoCorner, PostScriptLevel, SummaryFormat, SummaryOutputFormat, @@ -1545,6 +1550,289 @@ class ExtractImagesPayload(BaseModel): CmykChannel = Annotated[int, Field(ge=0, le=100)] +def _validate_zugferd_label_language(value: str) -> str: + trimmed = value.strip() + if not re.fullmatch( + r"[A-Za-z]{2,3}(?:-(?:[A-Za-z]{4}|[A-Za-z]{2}|\d{3}))?", trimmed + ) or not tag_is_valid(trimmed): + msg = "label_language must be a valid BCP 47 language tag." + raise ValueError(msg) + if trimmed.split("-", maxsplit=1)[0].casefold() not in {"en", "de", "fr"}: + msg = "label_language must use English, German, or French." + raise ValueError(msg) + return trimmed + + +def _validate_zugferd_font(value: Any) -> Any: + if not isinstance(value, str): + return value + trimmed = value.strip() + if trimmed.casefold() == "webdings": + msg = "ZUGFeRD invoice fonts must be text-capable; Webdings is unsupported." + raise ValueError(msg) + return trimmed + + +_ZugferdFontName = Annotated[ + PdfZugferdFontName, + BeforeValidator(_validate_zugferd_font), + Field(min_length=1, max_length=200), +] + + +class _PdfZugferdRenderOptions(BaseModel): + model_config = ConfigDict(extra="forbid") + + logo_corner: PdfZugferdLogoCorner | None = None + font: _ZugferdFontName | None = None + bold_font: _ZugferdFontName | None = None + text_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field( + validation_alias="text_color", + serialization_alias="text_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="text_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), + ] = None + text_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field( + validation_alias="text_color", + serialization_alias="text_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="text_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), + ] = None + muted_text_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field( + validation_alias="muted_text_color", + serialization_alias="muted_text_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="muted_text_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), + ] = None + muted_text_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field( + validation_alias="muted_text_color", + serialization_alias="muted_text_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="muted_text_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), + ] = None + accent_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field( + validation_alias="accent_color", + serialization_alias="accent_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="accent_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), + ] = None + accent_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field( + validation_alias="accent_color", + serialization_alias="accent_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="accent_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), + ] = None + border_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field( + validation_alias="border_color", + serialization_alias="border_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="border_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), + ] = None + border_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field( + validation_alias="border_color", + serialization_alias="border_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="border_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), + ] = None + table_header_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field( + validation_alias="table_header_color", + serialization_alias="table_header_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="table_header_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), + ] = None + table_header_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field( + validation_alias="table_header_color", + serialization_alias="table_header_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="table_header_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), + ] = None + table_alternate_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field( + validation_alias="table_alternate_color", + serialization_alias="table_alternate_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="table_alternate_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), + ] = None + table_alternate_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field( + validation_alias="table_alternate_color", + serialization_alias="table_alternate_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="table_alternate_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), + ] = None + footer_message: Annotated[str | None, Field(max_length=500)] = None + locale: PdfZugferdLocale | None = None + label_language: Annotated[ + PdfZugferdLabelLanguage | None, + AfterValidator(_validate_zugferd_label_language), + ] = None + currency_display: PdfZugferdCurrencyDisplay | None = None + date_format: str | None = None + decimal_precision: Annotated[int | None, Field(ge=0, le=4)] = None + + +class PdfZugferdPayload(BaseModel): + """Adapt uploaded invoice resources into a ZUGFeRD creation request.""" + + model_config = ConfigDict(extra="forbid") + + invoice: Annotated[ + list[PdfRestFile], + Field(min_length=1, max_length=1, serialization_alias="id"), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + "application/xml", + "text/xml", + error_msg="Invoice must be an XML file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] + pdf: Annotated[ + list[PdfRestFile] | None, + Field(min_length=1, max_length=1, serialization_alias="pdf_id"), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + logo: Annotated[ + list[PdfRestFile] | None, + Field(min_length=1, max_length=1, serialization_alias="logo_id"), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + "image/png", + "image/jpeg", + error_msg="Logo must be a PNG or JPEG file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + render_options: _PdfZugferdRenderOptions | None = None + regenerate_pdf: bool = False + output: Annotated[ + str | None, + Field(min_length=1), + AfterValidator(_validate_output_prefix), + ] = None + + @model_validator(mode="after") + def _validate_regeneration_source(self) -> PdfZugferdPayload: + if self.regenerate_pdf and self.pdf is None: + msg = "regenerate_pdf=True requires a PDF resource." + raise ValueError(msg) + return self + + class PdfLiteralRedactionModel(BaseModel): type: Literal["literal"] value: Annotated[str, Field(min_length=1)] diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 48b9ffa..62029d4 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -63,6 +63,13 @@ PdfStructuredTextTextAlignment, PdfTextColor, PdfXType, + PdfZugferdCurrencyDisplay, + PdfZugferdFontName, + PdfZugferdKnownFont, + PdfZugferdLabelLanguage, + PdfZugferdLocale, + PdfZugferdLogoCorner, + PdfZugferdRenderOptions, PngColorModel, PostScriptLevel, SummaryFormat, @@ -137,6 +144,13 @@ "PdfStructuredTextTextAlignment", "PdfTextColor", "PdfXType", + "PdfZugferdCurrencyDisplay", + "PdfZugferdFontName", + "PdfZugferdKnownFont", + "PdfZugferdLabelLanguage", + "PdfZugferdLocale", + "PdfZugferdLogoCorner", + "PdfZugferdRenderOptions", "PngColorModel", "PostScriptLevel", "SummaryFormat", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index e74e433..7782633 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -75,6 +75,13 @@ "PdfStructuredTextTextAlignment", "PdfTextColor", "PdfXType", + "PdfZugferdCurrencyDisplay", + "PdfZugferdFontName", + "PdfZugferdKnownFont", + "PdfZugferdLabelLanguage", + "PdfZugferdLocale", + "PdfZugferdLogoCorner", + "PdfZugferdRenderOptions", "PngColorModel", "PostScriptLevel", "SummaryFormat", @@ -159,6 +166,236 @@ class PdfRedactionInstruction(TypedDict): PdfColor = PdfRGBColor | PdfCMYKColor PdfTextColor = PdfColor +PdfZugferdLogoCorner: TypeAlias = Literal[ + "top-left", "top-right", "bottom-left", "bottom-right" +] +"""Logo placement used while rendering a ZUGFeRD invoice. + +Accepted values: + +- `top-left`: Place the logo in the upper-left corner. +- `top-right`: Place the logo in the upper-right corner. +- `bottom-left`: Place the logo in the lower-left corner. +- `bottom-right`: Place the logo in the lower-right corner. +""" + +PdfZugferdLocale: TypeAlias = Literal["de-DE", "fr-FR", "nl-NL", "en-GB"] +"""Locale used to format values in a rendered ZUGFeRD invoice. + +Accepted values: + +- `de-DE`: Format values using German (Germany) conventions. +- `fr-FR`: Format values using French (France) conventions. +- `nl-NL`: Format values using Dutch (Netherlands) conventions. +- `en-GB`: Format values using English (United Kingdom) conventions. +""" + +PdfZugferdCurrencyDisplay: TypeAlias = Literal["code", "symbol", "name"] +"""Currency representation used in a rendered ZUGFeRD invoice. + +Accepted values: + +- `code`: Display the ISO currency code. +- `symbol`: Display the currency symbol, falling back to its code when unknown. +- `name`: Display the currency name. +""" + +PdfZugferdLabelLanguage: TypeAlias = Literal["en", "de", "fr"] | str +"""BCP 47 language tag used for generated ZUGFeRD invoice labels. + +The primary language must be English, German, or French. Region subtags such +as ``en-US`` are accepted and normalized by pdfRest to their primary language. + +Accepted values: + +- `en`: Render English labels. +- `de`: Render German labels. +- `fr`: Render French labels. +""" + +PdfZugferdKnownFont: TypeAlias = Literal[ + "andalemono", + "arial", + "arialitalic", + "arialbold", + "arialblack", + "calibri", + "calibriitalic", + "calibribold", + "calibribolditalic", + "cambriaitalic", + "cambriabold", + "cambriabolditalic", + "candara", + "candaraitalic", + "candarabold", + "candarabolditalic", + "cantarellregular", + "cantarelloblique", + "cantarellbold", + "cantarellboldoblique", + "comicsans", + "comicsansbold", + "constantia", + "consolas", + "consolasitalic", + "consolasbold", + "consolasbolditalic", + "constantiaitalic", + "constantiabold", + "constantiabolditalic", + "corbel", + "corbelitalic", + "corbelbold", + "corbelbolditalic", + "courier", + "courieritalic", + "courierbold", + "courierbolditalic", + "dejavu", + "dejavu sans mono oblique", + "dejavu sans mono bold", + "dejavu sans mono bold oblique", + "georgia", + "georgiaitalic", + "georgiabold", + "georgiabolditalic", + "impact", + "tahoma", + "timesnewroman", + "timesnewromanitalic", + "timesnewromanbold", + "timesnewromanbolditalic", + "trebuchet", + "trebuchetitalic", + "trebuchetbold", + "trebuchetbolditalic", + "verdana", + "verdanaitalic", + "verdanabold", + "verdanabolditalic", +] +"""Published text-capable pdfRest font names for ZUGFeRD rendering. + +These are suggestions, not a closed service catalog. Another installed, +text-capable font may be supplied through ``PdfZugferdFontName``. Symbol fonts +such as Webdings are not valid for readable invoice text. + +Accepted values: + +- `andalemono`: Andale Mono. +- `arial`: Arial. +- `arialitalic`: Arial Italic. +- `arialbold`: Arial Bold. +- `arialblack`: Arial Black. +- `calibri`: Calibri. +- `calibriitalic`: Calibri Italic. +- `calibribold`: Calibri Bold. +- `calibribolditalic`: Calibri Bold Italic. +- `cambriaitalic`: Cambria Italic. +- `cambriabold`: Cambria Bold. +- `cambriabolditalic`: Cambria Bold Italic. +- `candara`: Candara. +- `candaraitalic`: Candara Italic. +- `candarabold`: Candara Bold. +- `candarabolditalic`: Candara Bold Italic. +- `cantarellregular`: Cantarell. +- `cantarelloblique`: Cantarell Oblique. +- `cantarellbold`: Cantarell Bold. +- `cantarellboldoblique`: Cantarell Bold Oblique. +- `comicsans`: Comic Sans. +- `comicsansbold`: Comic Sans Bold. +- `constantia`: Constantia. +- `consolas`: Consolas. +- `consolasitalic`: Consolas Italic. +- `consolasbold`: Consolas Bold. +- `consolasbolditalic`: Consolas Bold Italic. +- `constantiaitalic`: Constantia Italic. +- `constantiabold`: Constantia Bold. +- `constantiabolditalic`: Constantia Bold Italic. +- `corbel`: Corbel. +- `corbelitalic`: Corbel Italic. +- `corbelbold`: Corbel Bold. +- `corbelbolditalic`: Corbel Bold Italic. +- `courier`: Courier New. +- `courieritalic`: Courier New Italic. +- `courierbold`: Courier New Bold. +- `courierbolditalic`: Courier New Bold Italic. +- `dejavu`: DejaVu Sans Mono. +- `dejavu sans mono oblique`: DejaVu Sans Mono Oblique. +- `dejavu sans mono bold`: DejaVu Sans Mono Bold. +- `dejavu sans mono bold oblique`: DejaVu Sans Mono Bold Oblique. +- `georgia`: Georgia. +- `georgiaitalic`: Georgia Italic. +- `georgiabold`: Georgia Bold. +- `georgiabolditalic`: Georgia Bold Italic. +- `impact`: Impact. +- `tahoma`: Tahoma. +- `timesnewroman`: Times New Roman. +- `timesnewromanitalic`: Times New Roman Italic. +- `timesnewromanbold`: Times New Roman Bold. +- `timesnewromanbolditalic`: Times New Roman Bold Italic. +- `trebuchet`: Trebuchet. +- `trebuchetitalic`: Trebuchet Italic. +- `trebuchetbold`: Trebuchet Bold. +- `trebuchetbolditalic`: Trebuchet Bold Italic. +- `verdana`: Verdana. +- `verdanaitalic`: Verdana Italic. +- `verdanabold`: Verdana Bold. +- `verdanabolditalic`: Verdana Bold Italic. +""" + +PdfZugferdFontName: TypeAlias = PdfZugferdKnownFont | str +"""A published pdfRest font token or another installed text-capable font.""" + + +class PdfZugferdRenderOptions(TypedDict, total=False): + """Appearance and regional options for generated ZUGFeRD invoice PDFs. + + Attributes: + logo_corner: Optional corner for an uploaded logo. Defaults to + ``top-left``. + font: Optional non-empty body-text font name, at most 200 characters. + bold_font: Optional non-empty emphasized-text font name, at most 200 + characters. + text_color: Optional primary text color as RGB (0-255 channels) or CMYK + (0-100 percentage channels). + muted_text_color: Optional secondary text color as RGB or CMYK. + accent_color: Optional accent color as RGB or CMYK. + border_color: Optional table/rule border color as RGB or CMYK. + table_header_color: Optional table-header fill color as RGB or CMYK. + table_alternate_color: Optional alternating-row fill color as RGB or + CMYK. + footer_message: Optional footer text, at most 500 characters. ``None`` + leaves the footer unset. + locale: Optional locale for value formatting. Defaults to ``en-GB``. + label_language: Optional BCP 47 label language whose primary subtag is + ``en``, ``de``, or ``fr``. Defaults to ``en``. + currency_display: Optional currency representation. Defaults to + ``code``. + date_format: Optional .NET date format that preserves the complete day, + month, and four-digit year. ``None`` uses the renderer default. + decimal_precision: Optional decimal places from 0 through 4. Defaults + to 2. + """ + + logo_corner: PdfZugferdLogoCorner + font: PdfZugferdFontName + bold_font: PdfZugferdFontName + text_color: PdfColor + muted_text_color: PdfColor + accent_color: PdfColor + border_color: PdfColor + table_header_color: PdfColor + table_alternate_color: PdfColor + footer_message: str | None + locale: PdfZugferdLocale + label_language: PdfZugferdLabelLanguage + currency_display: PdfZugferdCurrencyDisplay + date_format: str | None + decimal_precision: int + + PdfStructuredTextDataPresentation: TypeAlias = Literal["source", "hierarchy"] """JSON/XML presentation accepted by structured document conversion helpers. diff --git a/tests/live/test_live_create_zugferd_pdf.py b/tests/live/test_live_create_zugferd_pdf.py new file mode 100644 index 0000000..aa1e835 --- /dev/null +++ b/tests/live/test_live_create_zugferd_pdf.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from typing import TypedDict + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile +from pdfrest.types import PdfZugferdRenderOptions + +from ..resources import get_test_resource_path + + +class ZugferdResources(TypedDict): + invoice: PdfRestFile + logo: PdfRestFile + pdf: PdfRestFile + + +RENDER_OPTION_CASES = [ + pytest.param( + PdfZugferdRenderOptions( + logo_corner="top-left", + font="courier", + text_color=(0, 0, 0), + locale="de-DE", + label_language="en", + currency_display="code", + decimal_precision=0, + ), + "top-left-de", + id="top-left-german-code", + ), + pytest.param( + PdfZugferdRenderOptions( + logo_corner="top-right", + font="DejaVu Sans", + muted_text_color=(10, 5, 0, 55), + locale="fr-FR", + label_language="de", + currency_display="symbol", + decimal_precision=1, + ), + "top-right-fr", + id="top-right-french-symbol", + ), + pytest.param( + PdfZugferdRenderOptions( + logo_corner="bottom-left", + accent_color=(20, 64, 115), + locale="nl-NL", + label_language="fr", + currency_display="name", + decimal_precision=4, + ), + "bottom-left-nl", + id="bottom-left-dutch-name", + ), + pytest.param( + PdfZugferdRenderOptions( + logo_corner="bottom-right", + table_header_color=(83, 44, 0, 55), + locale="en-GB", + label_language="en-US", + currency_display="code", + date_format="dd/MM/yyyy", + decimal_precision=2, + ), + "bottom-right-en", + id="bottom-right-english-region", + ), +] + + +@pytest.fixture(scope="module") +def uploaded_zugferd_resources( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> ZugferdResources: + paths = [ + get_test_resource_path("zugferd-minimum-invoice.xml"), + get_test_resource_path("signing_logo.png"), + get_test_resource_path("report.pdf"), + ] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + invoice, logo, pdf = client.files.create_from_paths(paths) + return ZugferdResources(invoice=invoice, logo=logo, pdf=pdf) + + +def _assert_zugferd_output( + output: PdfRestFile, + output_bytes: bytes, + output_prefix: str, +) -> None: + assert output.name.startswith(output_prefix) + assert output.name.endswith(".pdf") + assert output.type == "application/pdf" + assert output.size == len(output_bytes) + assert output.size > 0 + assert f"/resource/{output.id}" in str(output.url) + assert output_bytes.startswith(b"%PDF-") + + +@pytest.mark.parametrize(("render_options", "output"), RENDER_OPTION_CASES) +def test_live_create_zugferd_pdf_render_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + output: str, +) -> None: + invoice = uploaded_zugferd_resources["invoice"] + logo = uploaded_zugferd_resources["logo"] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.create_zugferd_pdf( + invoice, + logo=logo, + render_options=render_options, + output=output, + ) + output_bytes = client.files.read_bytes(response.output_file) + + assert response.input_ids == [invoice.id, logo.id] + assert response.warning is None + _assert_zugferd_output(response.output_file, output_bytes, output) + + +@pytest.mark.parametrize(("render_options", "output"), RENDER_OPTION_CASES) +@pytest.mark.asyncio +async def test_live_async_create_zugferd_pdf_render_options( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + output: str, +) -> None: + invoice = uploaded_zugferd_resources["invoice"] + logo = uploaded_zugferd_resources["logo"] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.create_zugferd_pdf( + invoice, + logo=logo, + render_options=render_options, + output=f"async-{output}", + ) + output_bytes = await client.files.read_bytes(response.output_file) + + assert response.input_ids == [invoice.id, logo.id] + assert response.warning is None + _assert_zugferd_output(response.output_file, output_bytes, f"async-{output}") + + +def test_live_create_zugferd_pdf_regenerates_mismatched_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, +) -> None: + invoice = uploaded_zugferd_resources["invoice"] + pdf = uploaded_zugferd_resources["pdf"] + logo = uploaded_zugferd_resources["logo"] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.create_zugferd_pdf( + invoice, + pdf=pdf, + logo=logo, + regenerate_pdf=True, + output="regenerated-zugferd", + ) + output_bytes = client.files.read_bytes(response.output_file) + + assert response.input_ids == [invoice.id, pdf.id, logo.id] + _assert_zugferd_output(response.output_file, output_bytes, "regenerated-zugferd") + + +@pytest.mark.asyncio +async def test_live_async_create_zugferd_pdf_regenerates_mismatched_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, +) -> None: + invoice = uploaded_zugferd_resources["invoice"] + pdf = uploaded_zugferd_resources["pdf"] + logo = uploaded_zugferd_resources["logo"] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.create_zugferd_pdf( + invoice, + pdf=pdf, + logo=logo, + regenerate_pdf=True, + output="async-regenerated-zugferd", + ) + output_bytes = await client.files.read_bytes(response.output_file) + + assert response.input_ids == [invoice.id, pdf.id, logo.id] + _assert_zugferd_output( + response.output_file, output_bytes, "async-regenerated-zugferd" + ) + + +def test_live_create_zugferd_pdf_rejects_invalid_render_option( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)locale|invalid"), + ): + client.create_zugferd_pdf( + uploaded_zugferd_resources["invoice"], + extra_body={"render_options": {"locale": "xx-XX"}}, + ) + + +@pytest.mark.asyncio +async def test_live_async_create_zugferd_pdf_rejects_invalid_render_option( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)locale|invalid"): + await client.create_zugferd_pdf( + uploaded_zugferd_resources["invoice"], + extra_body={"render_options": {"locale": "xx-XX"}}, + ) diff --git a/tests/resources/zugferd-minimum-invoice.xml b/tests/resources/zugferd-minimum-invoice.xml new file mode 100644 index 0000000..9fd3a95 --- /dev/null +++ b/tests/resources/zugferd-minimum-invoice.xml @@ -0,0 +1,51 @@ + + + + + + urn:factur-x.eu:1p0:minimum + + + + 471102 + 380 + + 20200305 + + + + + + Lieferant GmbH + + DE + + + 201/113/40209 + + + DE123456789 + + + + Kunden AG Frankreich + + + + + EUR + + 198.00 + 37.62 + 235.62 + 235.62 + + + + diff --git a/tests/test_create_zugferd_pdf.py b/tests/test_create_zugferd_pdf.py new file mode 100644 index 0000000..b877853 --- /dev/null +++ b/tests/test_create_zugferd_pdf.py @@ -0,0 +1,642 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, cast, get_args + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfZugferdPayload +from pdfrest.types import ( + PdfZugferdCurrencyDisplay, + PdfZugferdKnownFont, + PdfZugferdLocale, + PdfZugferdLogoCorner, + PdfZugferdRenderOptions, +) + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, +) + +KNOWN_FONTS = cast(tuple[PdfZugferdKnownFont, ...], get_args(PdfZugferdKnownFont)) +LOGO_CORNERS = cast(tuple[PdfZugferdLogoCorner, ...], get_args(PdfZugferdLogoCorner)) +LOCALES = cast(tuple[PdfZugferdLocale, ...], get_args(PdfZugferdLocale)) +CURRENCY_DISPLAYS = cast( + tuple[PdfZugferdCurrencyDisplay, ...], get_args(PdfZugferdCurrencyDisplay) +) + + +def _make_file(name: str, mime_type: str, *, prefix: int | None = None) -> PdfRestFile: + file_id = PdfRestFileID.generate(prefix) + return PdfRestFile.model_validate( + { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": mime_type, + "size": 1024, + "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "scheduledDeletionTimeUtc": None, + } + ) + + +def _dump(payload: dict[str, Any]) -> dict[str, Any]: + return PdfZugferdPayload.model_validate(payload).model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + +def _assert_timeout(value: float | dict[str, float] | None, expected: float) -> None: + assert value is not None + if isinstance(value, dict): + assert value + assert all(component == pytest.approx(expected) for component in value.values()) + else: + assert value == pytest.approx(expected) + + +def test_zugferd_payload_serializes_resources_and_render_options() -> None: + invoice = _make_file("invoice.xml", "application/xml", prefix=1) + pdf = _make_file("invoice.pdf", "application/pdf") + logo = _make_file("logo.png", "image/png", prefix=2) + + payload = _dump( + { + "invoice": invoice, + "pdf": pdf, + "logo": logo, + "regenerate_pdf": True, + "output": "customer-invoice", + "render_options": { + "logo_corner": "top-right", + "font": " arial ", + "bold_font": "arialbold", + "text_color": (26, 31, 41), + "muted_text_color": (10, 5, 0, 55), + "accent_color": (20, 64, 115), + "border_color": (12, 7, 0, 20), + "table_header_color": (20, 64, 115), + "table_alternate_color": (4, 2, 0, 2), + "footer_message": "Thank you for your business.", + "locale": "de-DE", + "label_language": "en-US", + "currency_display": "symbol", + "date_format": "dd.MM.yyyy", + "decimal_precision": 3, + }, + } + ) + + assert payload == { + "id": str(invoice.id), + "pdf_id": str(pdf.id), + "logo_id": str(logo.id), + "render_options": { + "logo_corner": "top-right", + "font": "arial", + "bold_font": "arialbold", + "text_color_rgb": [26, 31, 41], + "muted_text_color_cmyk": [10, 5, 0, 55], + "accent_color_rgb": [20, 64, 115], + "border_color_cmyk": [12, 7, 0, 20], + "table_header_color_rgb": [20, 64, 115], + "table_alternate_color_cmyk": [4, 2, 0, 2], + "footer_message": "Thank you for your business.", + "locale": "de-DE", + "label_language": "en-US", + "currency_display": "symbol", + "date_format": "dd.MM.yyyy", + "decimal_precision": 3, + }, + "regenerate_pdf": True, + "output": "customer-invoice", + } + + +@pytest.mark.parametrize( + "field", + [ + pytest.param("text_color", id="text"), + pytest.param("muted_text_color", id="muted-text"), + pytest.param("accent_color", id="accent"), + pytest.param("border_color", id="border"), + pytest.param("table_header_color", id="table-header"), + pytest.param("table_alternate_color", id="table-alternate"), + ], +) +@pytest.mark.parametrize( + ("color", "wire_suffix"), + [ + pytest.param((0, 127, 255), "rgb", id="rgb"), + pytest.param((0, 25, 50, 100), "cmyk", id="cmyk"), + ], +) +def test_zugferd_payload_routes_unified_colors( + field: str, + color: tuple[int, ...], + wire_suffix: str, +) -> None: + invoice = _make_file("invoice.xml", "text/xml") + + payload = _dump({"invoice": invoice, "render_options": {field: color}}) + + assert payload["render_options"] == {f"{field}_{wire_suffix}": list(color)} + + +@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str) +def test_zugferd_payload_accepts_every_published_font( + font: PdfZugferdKnownFont, +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + payload = _dump({"invoice": invoice, "render_options": {"font": font}}) + + assert payload["render_options"] == {"font": font} + + +@pytest.mark.parametrize( + ("field", "value"), + [ + *( + pytest.param("logo_corner", value, id=f"logo-{value}") + for value in LOGO_CORNERS + ), + *(pytest.param("locale", value, id=f"locale-{value}") for value in LOCALES), + *( + pytest.param("currency_display", value, id=f"currency-{value}") + for value in CURRENCY_DISPLAYS + ), + pytest.param("label_language", "en", id="language-en"), + pytest.param("label_language", "de", id="language-de"), + pytest.param("label_language", "fr", id="language-fr"), + pytest.param("label_language", "fr-FR", id="language-region"), + ], +) +def test_zugferd_payload_accepts_render_option_literals(field: str, value: str) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + payload = _dump({"invoice": invoice, "render_options": {field: value}}) + + assert payload["render_options"] == {field: value} + + +@pytest.mark.parametrize( + "decimal_precision", + [pytest.param(0, id="minimum"), pytest.param(4, id="maximum")], +) +def test_zugferd_payload_accepts_decimal_precision_boundaries( + decimal_precision: int, +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + payload = _dump( + { + "invoice": invoice, + "render_options": {"decimal_precision": decimal_precision}, + } + ) + + assert payload["render_options"]["decimal_precision"] == decimal_precision + + +@pytest.mark.parametrize( + ("options", "error_match"), + [ + pytest.param( + {"decimal_precision": -1}, "greater than or equal to 0", id="precision-low" + ), + pytest.param( + {"decimal_precision": 5}, "less than or equal to 4", id="precision-high" + ), + pytest.param( + {"text_color": (0, 256, 0)}, "less than or equal to 255", id="rgb-high" + ), + pytest.param( + {"text_color": (0, 101, 0, 0)}, "less than or equal to 100", id="cmyk-high" + ), + pytest.param({"text_color": (0, 0)}, "exactly 3.*or 4", id="channel-count"), + pytest.param({"font": " "}, "at least 1 item", id="font-empty"), + pytest.param({"font": "x" * 201}, "at most 200 items", id="font-long"), + pytest.param({"font": "Webdings"}, "Webdings is unsupported", id="font-symbol"), + pytest.param( + {"footer_message": "x" * 501}, "at most 500 characters", id="footer-long" + ), + pytest.param( + {"label_language": "nl"}, + "English, German, or French", + id="language-primary", + ), + pytest.param( + {"label_language": "not_a_tag"}, "valid BCP 47", id="language-shape" + ), + pytest.param( + {"unsupported": True}, "Extra inputs are not permitted", id="unknown-field" + ), + ], +) +def test_zugferd_payload_rejects_invalid_render_options( + options: dict[str, Any], + error_match: str, +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + with pytest.raises(ValidationError, match=error_match): + PdfZugferdPayload.model_validate( + {"invoice": invoice, "render_options": options} + ) + + +def test_zugferd_payload_accepts_custom_installed_font() -> None: + invoice = _make_file("invoice.xml", "application/xml") + + payload = _dump({"invoice": invoice, "render_options": {"font": "Corporate Sans"}}) + + assert payload["render_options"] == {"font": "Corporate Sans"} + + +@pytest.mark.parametrize( + ("payload", "error_match"), + [ + pytest.param( + {"invoice": _make_file("invoice.pdf", "application/pdf")}, + "Invoice must be an XML file", + id="invoice-mime", + ), + pytest.param( + { + "invoice": _make_file("invoice.xml", "application/xml"), + "pdf": _make_file("source.png", "image/png"), + }, + "Must be a PDF file", + id="pdf-mime", + ), + pytest.param( + { + "invoice": _make_file("invoice.xml", "application/xml"), + "logo": _make_file("logo.gif", "image/gif"), + }, + "Logo must be a PNG or JPEG file", + id="logo-mime", + ), + pytest.param( + { + "invoice": [ + _make_file("one.xml", "application/xml"), + _make_file("two.xml", "text/xml"), + ] + }, + "at most 1 item", + id="invoice-cardinality", + ), + pytest.param( + { + "invoice": _make_file("invoice.xml", "application/xml"), + "regenerate_pdf": True, + }, + "requires a PDF resource", + id="regeneration-dependency", + ), + ], +) +def test_zugferd_payload_rejects_invalid_resources( + payload: dict[str, Any], error_match: str +) -> None: + with pytest.raises(ValidationError, match=error_match): + PdfZugferdPayload.model_validate(payload) + + +def test_create_zugferd_pdf_success_and_response_mapping() -> None: + invoice = _make_file("invoice.xml", "application/xml", prefix=1) + pdf = _make_file("invoice.pdf", "application/pdf") + logo = _make_file("logo.jpg", "image/jpeg", prefix=2) + output_id = PdfRestFileID.generate() + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + assert request.url.path == "/zugferd-pdf" + assert request.headers["Api-Key"] == VALID_API_KEY + assert json.loads(request.content) == { + "id": str(invoice.id), + "pdf_id": str(pdf.id), + "logo_id": str(logo.id), + "render_options": { + "logo_corner": "bottom-right", + "text_color_rgb": [1, 2, 3], + "accent_color_cmyk": [4, 3, 2, 1], + }, + "regenerate_pdf": True, + "output": "zugferd-result", + } + return httpx.Response( + 200, + json={ + "inputId": [str(invoice.id), str(pdf.id), str(logo.id)], + "outputId": str(output_id), + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}", + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "zugferd-result.pdf", "application/pdf" + ), + ) + + options: PdfZugferdRenderOptions = { + "logo_corner": "bottom-right", + "text_color": (1, 2, 3), + "accent_color": (4, 3, 2, 1), + } + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = client.create_zugferd_pdf( + invoice, + pdf=pdf, + logo=logo, + render_options=options, + regenerate_pdf=True, + output="zugferd-result", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.input_ids == [invoice.id, pdf.id, logo.id] + assert response.output_file.id == output_id + assert response.output_file.name == "zugferd-result.pdf" + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_create_zugferd_pdf_success_and_response_mapping() -> None: + invoice = _make_file("invoice.xml", "text/xml", prefix=1) + pdf = _make_file("invoice.pdf", "application/pdf") + logo = _make_file("logo.png", "image/png", prefix=2) + output_id = PdfRestFileID.generate() + + async def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + assert request.url.path == "/zugferd-pdf" + assert request.headers["Api-Key"] == ASYNC_API_KEY + assert json.loads(request.content) == { + "id": str(invoice.id), + "pdf_id": str(pdf.id), + "logo_id": str(logo.id), + "render_options": { + "font": "Corporate Sans", + "bold_font": "arialbold", + "muted_text_color_cmyk": [0, 0, 0, 50], + "locale": "fr-FR", + "label_language": "fr-FR", + "currency_display": "name", + "decimal_precision": 4, + }, + "regenerate_pdf": True, + "output": "zugferd-async", + } + return httpx.Response( + 200, + json={ + "inputId": [str(invoice.id), str(pdf.id), str(logo.id)], + "outputId": str(output_id), + "outputUrl": f"https://api.pdfrest.com/resource/{output_id}", + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "zugferd-async.pdf", "application/pdf" + ), + ) + + options: PdfZugferdRenderOptions = { + "font": "Corporate Sans", + "bold_font": "arialbold", + "muted_text_color": (0, 0, 0, 50), + "locale": "fr-FR", + "label_language": "fr-FR", + "currency_display": "name", + "decimal_precision": 4, + } + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = await client.create_zugferd_pdf( + invoice, + pdf=pdf, + logo=logo, + render_options=options, + regenerate_pdf=True, + output="zugferd-async", + ) + + assert response.input_ids == [invoice.id, pdf.id, logo.id] + assert response.output_file.id == output_id + assert response.output_file.name == "zugferd-async.pdf" + + +def test_create_zugferd_pdf_request_customization() -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["trace"] == "sync" + assert request.headers["X-Trace"] == "zugferd" + _assert_timeout(request.extensions.get("timeout"), 14.0) + assert json.loads(request.content) == { + "id": str(invoice.id), + "regenerate_pdf": False, + "server_debug": True, + } + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = client.create_zugferd_pdf( + invoice, + extra_query={"trace": "sync"}, + extra_headers={"X-Trace": "zugferd"}, + extra_body={"server_debug": True}, + timeout=14.0, + ) + + assert response.input_id == invoice.id + assert response.output_files == [] + + +@pytest.mark.asyncio +async def test_async_create_zugferd_pdf_request_customization() -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["trace"] == "async" + assert request.headers["X-Trace"] == "zugferd-async" + _assert_timeout(request.extensions.get("timeout"), 21.0) + assert json.loads(request.content) == { + "id": str(invoice.id), + "regenerate_pdf": False, + "server_debug": True, + } + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = await client.create_zugferd_pdf( + invoice, + extra_query={"trace": "async"}, + extra_headers={"X-Trace": "zugferd-async"}, + extra_body={"server_debug": True}, + timeout=21.0, + ) + + assert response.input_id == invoice.id + assert response.output_files == [] + + +@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str) +def test_create_zugferd_pdf_accepts_every_published_font( + font: PdfZugferdKnownFont, +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert json.loads(request.content)["render_options"] == {"font": font} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.create_zugferd_pdf(invoice, render_options={"font": font}) + + +@pytest.mark.parametrize("font", KNOWN_FONTS, ids=str) +@pytest.mark.asyncio +async def test_async_create_zugferd_pdf_accepts_every_published_font( + font: PdfZugferdKnownFont, +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert json.loads(request.content)["render_options"] == {"font": font} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.create_zugferd_pdf(invoice, render_options={"font": font}) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + *( + pytest.param("logo_corner", value, id=f"logo-{value}") + for value in LOGO_CORNERS + ), + *(pytest.param("locale", value, id=f"locale-{value}") for value in LOCALES), + *( + pytest.param("currency_display", value, id=f"currency-{value}") + for value in CURRENCY_DISPLAYS + ), + pytest.param("label_language", "en", id="language-en"), + pytest.param("label_language", "de", id="language-de"), + pytest.param("label_language", "fr", id="language-fr"), + ], +) +def test_create_zugferd_pdf_accepts_every_render_literal( + field: str, value: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert json.loads(request.content)["render_options"] == {field: value} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.create_zugferd_pdf( + invoice, + render_options=cast(PdfZugferdRenderOptions, cast(object, {field: value})), + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + *( + pytest.param("logo_corner", value, id=f"logo-{value}") + for value in LOGO_CORNERS + ), + *(pytest.param("locale", value, id=f"locale-{value}") for value in LOCALES), + *( + pytest.param("currency_display", value, id=f"currency-{value}") + for value in CURRENCY_DISPLAYS + ), + pytest.param("label_language", "en", id="language-en"), + pytest.param("label_language", "de", id="language-de"), + pytest.param("label_language", "fr", id="language-fr"), + ], +) +@pytest.mark.asyncio +async def test_async_create_zugferd_pdf_accepts_every_render_literal( + field: str, value: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert json.loads(request.content)["render_options"] == {field: value} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.create_zugferd_pdf( + invoice, + render_options=cast(PdfZugferdRenderOptions, cast(object, {field: value})), + ) + + +def test_create_zugferd_pdf_rejects_invalid_resource_before_transport() -> None: + invoice = _make_file("invoice.pdf", "application/pdf") + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("transport must not run for invalid local input") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match="Invoice must be an XML file"), + ): + client.create_zugferd_pdf(invoice) + + +@pytest.mark.asyncio +async def test_async_create_zugferd_pdf_rejects_invalid_resource_before_transport() -> ( + None +): + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("transport must not run for invalid local input") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match="requires a PDF resource"): + await client.create_zugferd_pdf(invoice, regenerate_pdf=True)