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