diff --git a/.agents/skills/pdfrest-client-api/SKILL.md b/.agents/skills/pdfrest-client-api/SKILL.md index 115f856..dc8d90c 100644 --- a/.agents/skills/pdfrest-client-api/SKILL.md +++ b/.agents/skills/pdfrest-client-api/SKILL.md @@ -147,6 +147,15 @@ body to the current API contract. - Do not change positional meaning, narrow accepted types, alter established defaults, remove arguments, or change response shapes as part of an API adaptation. +- Treat every existing public export as part of the compatibility surface, not + only client methods. Preserve public type aliases at runtime and under static + type checking: equality, `typing.get_origin()`, `typing.get_args()`, + annotations, and assignability must remain equivalent to the base branch. + Reusing or composing a public type is acceptable only when those observable + contracts remain unchanged. +- Run the existing tests for unrelated services unchanged. If a new endpoint + makes one fail, fix the compatibility break instead of rewriting that test to + accept the regression. Add focused tests for the new behavior separately. - If the server requires a breaking SDK change and no faithful compatible translation/default exists, stop and ask the user for a migration decision before editing. @@ -385,6 +394,10 @@ documentation with its types; do not hand-copy a shape schema into Markdown. placement and an `Accepted values:` list whose bullets document each literal spelling and meaning. Inspect generated HTML to confirm the list renders with the alias rather than only in the source file. +- Make every public literal's rendered documentation self-contained. When an + alias reuses or extends another literal, expand the complete applicable list + at that alias instead of directing readers to combine values from another + entry. - Public types are re-exported through `pdfrest.types`. Verify that the rendered reference resolves the re-export to its source union and member types. A self-reference such as `PdfAddShapeObject = PdfAddShapeObject` is a rendering @@ -404,3 +417,7 @@ Run the full pytest suite and `uvx nox -s tests` when practical. For API reference changes, also run the strict docs build. Run the focused example and the example matrix as described above. Report the OpenAPI operation inspected, files changed, checks run, checks skipped, and any live-validation limitation. +Before completion, compare every touched pre-existing public interface with the +base branch at runtime and with the type checker, run its original unrelated +tests without adaptations, and inspect rendered HTML for complete literal value +lists rather than relying on a successful documentation build alone. 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..d777bd8 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,6 +167,307 @@ 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. 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: + +- `en`: Render English labels. +- `de`: Render German labels. +- `fr`: Render French labels. +""" + +PdfKnownTextFont: 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. + +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: + +- `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. +""" + +PdfZugferdKnownFont: TypeAlias = PdfKnownTextFont +"""Published text-capable pdfRest font names for ZUGFeRD rendering. + +These are suggestions, not a closed service catalog. ``PdfZugferdFontName`` +also accepts other installed text-capable fonts. + +Accepted values: + +- `andalemono`: Andale Mono. +- `arial`: Arial. +- `arialitalic`: Arial Italic. +- `arialbold`: Arial Bold. +- `arialblack`: Arial Black. +- `calibri`: Calibri. +- `calibriitalic`: Calibri Italic. +- `calibribold`: Calibri Bold. +- `calibribolditalic`: Calibri Bold Italic. +- `cambriaitalic`: Cambria Italic. +- `cambriabold`: Cambria Bold. +- `cambriabolditalic`: Cambria Bold Italic. +- `candara`: Candara. +- `candaraitalic`: Candara Italic. +- `candarabold`: Candara Bold. +- `candarabolditalic`: Candara Bold Italic. +- `cantarellregular`: Cantarell. +- `cantarelloblique`: Cantarell Oblique. +- `cantarellbold`: Cantarell Bold. +- `cantarellboldoblique`: Cantarell Bold Oblique. +- `comicsans`: Comic Sans. +- `comicsansbold`: Comic Sans Bold. +- `constantia`: Constantia. +- `consolas`: Consolas. +- `consolasitalic`: Consolas Italic. +- `consolasbold`: Consolas Bold. +- `consolasbolditalic`: Consolas Bold Italic. +- `constantiaitalic`: Constantia Italic. +- `constantiabold`: Constantia Bold. +- `constantiabolditalic`: Constantia Bold Italic. +- `corbel`: Corbel. +- `corbelitalic`: Corbel Italic. +- `corbelbold`: Corbel Bold. +- `corbelbolditalic`: Corbel Bold Italic. +- `courier`: Courier New. +- `courieritalic`: Courier New Italic. +- `courierbold`: Courier New Bold. +- `courierbolditalic`: Courier New Bold Italic. +- `dejavu`: DejaVu Sans Mono. +- `dejavu sans mono oblique`: DejaVu Sans Mono Oblique. +- `dejavu sans mono bold`: DejaVu Sans Mono Bold. +- `dejavu sans mono bold oblique`: DejaVu Sans Mono Bold Oblique. +- `georgia`: Georgia. +- `georgiaitalic`: Georgia Italic. +- `georgiabold`: Georgia Bold. +- `georgiabolditalic`: Georgia Bold Italic. +- `impact`: Impact. +- `tahoma`: Tahoma. +- `timesnewroman`: Times New Roman. +- `timesnewromanitalic`: Times New Roman Italic. +- `timesnewromanbold`: Times New Roman Bold. +- `timesnewromanbolditalic`: Times New Roman Bold Italic. +- `trebuchet`: Trebuchet. +- `trebuchetitalic`: Trebuchet Italic. +- `trebuchetbold`: Trebuchet Bold. +- `trebuchetbolditalic`: Trebuchet Bold Italic. +- `verdana`: Verdana. +- `verdanaitalic`: Verdana Italic. +- `verdanabold`: Verdana Bold. +- `verdanabolditalic`: Verdana Bold Italic. +""" + +PdfZugferdFontName: TypeAlias = PdfZugferdKnownFont | str +"""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..1e5cba4 --- /dev/null +++ b/tests/live/test_live_create_zugferd_pdf.py @@ -0,0 +1,774 @@ +from __future__ import annotations + +from typing import Literal, TypedDict, cast, get_args + +import pytest +from annotated_types import Ge, Le + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import ExtractedTextDocument, PdfRestFile +from pdfrest.models._internal import _PdfZugferdRenderOptions +from pdfrest.types import ( + PdfZugferdCurrencyDisplay, + PdfZugferdKnownFont, + PdfZugferdLabelLanguage, + PdfZugferdLocale, + PdfZugferdLogoCorner, + PdfZugferdRenderOptions, +) + +from ..resources import get_test_resource_path + + +class ZugferdResources(TypedDict): + invoice: PdfRestFile + multi_item_invoice: PdfRestFile + logo: PdfRestFile + mismatched_pdf: PdfRestFile + matching_pdf: PdfRestFile + + +LOGO_CORNERS = cast(tuple[PdfZugferdLogoCorner, ...], get_args(PdfZugferdLogoCorner)) +LOCALES = cast(tuple[PdfZugferdLocale, ...], get_args(PdfZugferdLocale)) +CURRENCY_DISPLAYS = cast( + tuple[PdfZugferdCurrencyDisplay, ...], get_args(PdfZugferdCurrencyDisplay) +) +LABEL_LANGUAGES = cast(tuple[str, ...], get_args(get_args(PdfZugferdLabelLanguage)[0])) +KNOWN_FONTS = cast(tuple[PdfZugferdKnownFont, ...], get_args(PdfZugferdKnownFont)) + + +def _decimal_precision_values() -> tuple[int, int, int, int]: + field = _PdfZugferdRenderOptions.model_fields["decimal_precision"] + minimum = next(item.ge for item in field.metadata if isinstance(item, Ge)) + maximum = next(item.le for item in field.metadata if isinstance(item, Le)) + assert isinstance(minimum, int) + assert isinstance(maximum, int) + return minimum, minimum + 1, maximum - 1, maximum + + +DECIMAL_PRECISION_VALUES = _decimal_precision_values() + + +ISOLATED_RENDER_OPTION_CASES = [ + *( + pytest.param( + PdfZugferdRenderOptions(logo_corner=value), + f"logo-{value}", + id=f"logo-{value}", + ) + for value in LOGO_CORNERS + ), + *( + pytest.param( + PdfZugferdRenderOptions(locale=value), + f"locale-{value}", + id=f"locale-{value}", + ) + for value in LOCALES + ), + *( + pytest.param( + PdfZugferdRenderOptions(label_language=value), + f"language-{value}", + id=f"language-{value}", + ) + for value in LABEL_LANGUAGES + ), + *( + pytest.param( + PdfZugferdRenderOptions(currency_display=value), + f"currency-{value}", + id=f"currency-{value}", + ) + for value in CURRENCY_DISPLAYS + ), + *( + pytest.param( + PdfZugferdRenderOptions(decimal_precision=value), + f"precision-{value}", + id=f"precision-{value}", + ) + for value in DECIMAL_PRECISION_VALUES + ), +] + +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({"locale": "en-US"}, r"(?i)locale|invalid", id="locale"), + pytest.param( + {"label_language": "nl"}, + r"(?i)label.language|English|German|French|invalid", + id="label-language", + ), + pytest.param( + {"decimal_precision": -1}, + r"(?i)decimal|precision|range|invalid", + 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("zugferd-basic-two-items.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, multi_item_invoice, logo, mismatched_pdf = ( + client.files.create_from_paths(paths) + ) + matching_pdf = client.convert_invoice_xml_to_zugferd_pdf( + invoice, + output="matching-zugferd-source", + ).output_file + return ZugferdResources( + invoice=invoice, + multi_item_invoice=multi_item_invoice, + logo=logo, + mismatched_pdf=mismatched_pdf, + matching_pdf=matching_pdf, + ) + + +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]) + + +def _font_names(document: ExtractedTextDocument) -> set[str]: + assert document.words is not None + names = {word.style.font.name for word in document.words if word.style is not None} + assert names + return names + + +FONT_RENDER_OPTION_CASES = [ + pytest.param( + PdfZugferdRenderOptions(font="courier"), + ("courier",), + "known-body-font", + id="known-body-font", + ), + pytest.param( + PdfZugferdRenderOptions(font="Noto Sans"), + ("noto", "sans"), + "installed-body-font", + id="installed-body-font", + ), + pytest.param( + PdfZugferdRenderOptions(bold_font="courierbold"), + ("courier", "bold"), + "bold-font", + id="bold-font", + ), +] + +OBSERVABLE_VISUAL_CASES = [ + pytest.param( + PdfZugferdRenderOptions(text_color=(255, 0, 0)), + "text-color", + None, + "invoice", + id="text-color", + ), + pytest.param( + PdfZugferdRenderOptions(muted_text_color=(255, 0, 0)), + "muted-text-color", + None, + "invoice", + id="muted-text-color", + ), + pytest.param( + PdfZugferdRenderOptions(accent_color=(255, 0, 0)), + "accent-color", + None, + "invoice", + id="accent-color", + ), + pytest.param( + PdfZugferdRenderOptions(border_color=(255, 0, 0)), + "border-color", + None, + "invoice", + id="border-color", + ), + pytest.param( + PdfZugferdRenderOptions(table_header_color=(255, 0, 0)), + "table-header-color", + None, + "multi_item_invoice", + id="table-header-color", + ), + pytest.param( + PdfZugferdRenderOptions(table_alternate_color=(255, 255, 0)), + "alternate-row-color", + None, + "multi_item_invoice", + id="alternate-row-color", + ), + pytest.param( + PdfZugferdRenderOptions(footer_message="Observable review footer"), + "footer-message", + "Observable review footer", + "invoice", + id="footer-message", + ), + pytest.param( + PdfZugferdRenderOptions(date_format="yyyy/MM/dd"), + "date-format", + "2020/03/05", + "invoice", + id="date-format", + ), +] + + +@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}") + + +@pytest.mark.parametrize( + ("render_options", "expected_font_fragments", "output"), + FONT_RENDER_OPTION_CASES, +) +def test_live_convert_invoice_xml_to_zugferd_pdf_uses_requested_font_resource( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + expected_font_fragments: tuple[str, ...], + output: str, +) -> None: + assert "courier" in KNOWN_FONTS + assert "Noto Sans" not in KNOWN_FONTS + invoice = uploaded_zugferd_resources["invoice"] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_invoice_xml_to_zugferd_pdf( + invoice, + render_options=render_options, + output=output, + ) + output_bytes = client.files.read_bytes(response.output_file) + extracted = client.extract_pdf_text( + response.output_file, + word_style=True, + ) + + names = {name.lower() for name in _font_names(extracted)} + assert any( + all(fragment in name for fragment in expected_font_fragments) for name in names + ) + assert response.warning is None + _assert_zugferd_output(response.output_file, output_bytes, output) + + +@pytest.mark.parametrize( + ("render_options", "expected_font_fragments", "output"), + FONT_RENDER_OPTION_CASES, +) +@pytest.mark.asyncio +async def test_live_async_convert_invoice_xml_to_zugferd_pdf_uses_requested_font_resource( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + expected_font_fragments: tuple[str, ...], + output: str, +) -> None: + assert "courier" in KNOWN_FONTS + assert "Noto Sans" not in KNOWN_FONTS + invoice = uploaded_zugferd_resources["invoice"] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_invoice_xml_to_zugferd_pdf( + invoice, + render_options=render_options, + output=f"async-{output}", + ) + output_bytes = await client.files.read_bytes(response.output_file) + extracted = await client.extract_pdf_text( + response.output_file, + word_style=True, + ) + + names = {name.lower() for name in _font_names(extracted)} + assert any( + all(fragment in name for fragment in expected_font_fragments) for name in names + ) + assert response.warning is None + _assert_zugferd_output( + response.output_file, + output_bytes, + f"async-{output}", + ) + + +@pytest.mark.parametrize( + ("render_options", "output", "expected_text", "invoice_key"), + OBSERVABLE_VISUAL_CASES, +) +def test_live_convert_invoice_xml_to_zugferd_pdf_observes_each_visual_control( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + output: str, + expected_text: str | None, + invoice_key: Literal["invoice", "multi_item_invoice"], +) -> None: + invoice = uploaded_zugferd_resources[invoice_key] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + baseline = client.convert_invoice_xml_to_zugferd_pdf( + invoice, + output=f"baseline-{output}", + ) + response = client.convert_invoice_xml_to_zugferd_pdf( + invoice, + render_options=render_options, + output=output, + ) + output_bytes = client.files.read_bytes(response.output_file) + baseline_png = _render_first_page( + client, baseline.output_file, f"base-{output}" + ) + customized_png = _render_first_page(client, response.output_file, output) + extracted = ( + client.extract_pdf_text(response.output_file) + if expected_text is not None + else None + ) + + assert customized_png != baseline_png + if expected_text is not None: + assert extracted is not None + assert extracted.full_text is not None + assert expected_text in (extracted.full_text.document_text or "") + assert response.warning is None + _assert_zugferd_output(response.output_file, output_bytes, output) + + +@pytest.mark.parametrize( + ("render_options", "output", "expected_text", "invoice_key"), + OBSERVABLE_VISUAL_CASES, +) +@pytest.mark.asyncio +async def test_live_async_convert_invoice_xml_to_zugferd_pdf_observes_each_visual_control( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_zugferd_resources: ZugferdResources, + render_options: PdfZugferdRenderOptions, + output: str, + expected_text: str | None, + invoice_key: Literal["invoice", "multi_item_invoice"], +) -> None: + invoice = uploaded_zugferd_resources[invoice_key] + 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=f"async-baseline-{output}", + ) + response = await client.convert_invoice_xml_to_zugferd_pdf( + invoice, + render_options=render_options, + output=f"async-{output}", + ) + output_bytes = await client.files.read_bytes(response.output_file) + baseline_png = await _render_first_page_async( + client, + baseline.output_file, + f"async-base-{output}", + ) + customized_png = await _render_first_page_async( + client, + response.output_file, + f"async-{output}", + ) + extracted = ( + await client.extract_pdf_text(response.output_file) + if expected_text is not None + else None + ) + + assert customized_png != baseline_png + if expected_text is not None: + assert extracted is not None + assert extracted.full_text is not None + assert expected_text in (extracted.full_text.document_text or "") + assert response.warning is None + _assert_zugferd_output( + response.output_file, + output_bytes, + f"async-{output}", + ) + + +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) + source_png = _render_first_page( + client, + mismatched_pdf, + "source-before-replacement", + ) + 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) + output_png = _render_first_page( + client, + response.output_file, + "replacement-permitted", + ) + extracted = client.extract_pdf_text(response.output_file) + + assert response.input_ids == [invoice.id, mismatched_pdf.id, logo.id] + assert response.warning is None + assert output_bytes != source_bytes + assert output_png != source_png + assert extracted.full_text is not None + assert "Replacement permitted" in (extracted.full_text.document_text or "") + _assert_zugferd_output(response.output_file, output_bytes, "replacement-permitted") + + +@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) + source_png = await _render_first_page_async( + client, + mismatched_pdf, + "async-source-before-replacement", + ) + response = await client.convert_pdf_to_zugferd_pdf( + mismatched_pdf, + invoice=invoice, + 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) + output_png = await _render_first_page_async( + client, + response.output_file, + "async-replacement-permitted", + ) + extracted = await client.extract_pdf_text(response.output_file) + + assert response.input_ids == [invoice.id, mismatched_pdf.id, logo.id] + assert response.warning is None + assert output_bytes != source_bytes + assert output_png != source_png + assert extracted.full_text is not None + assert "Async replacement permitted" in (extracted.full_text.document_text or "") + _assert_zugferd_output( + response.output_file, + output_bytes, + "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-basic-two-items.xml b/tests/resources/zugferd-basic-two-items.xml new file mode 100644 index 0000000..2ca8180 --- /dev/null +++ b/tests/resources/zugferd-basic-two-items.xml @@ -0,0 +1,234 @@ + + + + + + + + urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic + + + + 471102 + 380 + + 20260305 + + + Rechnung gemäß Bestellung vom 01.03.2026. + + + Lieferant GmbH +Lieferantenstraße 20 +80333 München +Deutschland +Geschäftsführer: Hans Muster +Handelsregisternummer: H A 123 + + REG + + + + + + 1 + + + 4012345001235 + Unsere Art.-Nr.: TB100A4 Trennblätter A4 + + + + 9.90 + + + + 20.0000 + + + + VAT + S + 19 + + + 198.00 + + + + + + 2 + + + 4012345001242 + Unsere Art.-Nr.: ST200A4 Schreibunterlage A4 + + + + 12.50 + + + + 4.0000 + + + + VAT + S + 19 + + + 50.00 + + + + + + 4000001123452 + Lieferant GmbH + + 80333 + Lieferantenstraße 20 + München + DE + + + 201/113/40209 + + + DE123456789 + + + + GE2020211 + 4000001987658 + Kunden AG Mitte + + 69876 + Hans Muster + Kundenstraße 15 + Frankfurt + DE + + + + + + + 20260305 + + + + + EUR + + 47.12 + VAT + 248.00 + S + 19.00 + + + Zahlbar innerhalb 30 Tagen netto bis 04.04.2026, 3% Skonto innerhalb 10 Tagen bis 15.03.2026. + + 20260404 + + + + 248.00 + 0.00 + 0.00 + 248.00 + 47.12 + 295.12 + 295.12 + + + + 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..d59b146 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -2,7 +2,7 @@ import json from collections.abc import Callable -from typing import Any, cast, get_args +from typing import Any, Literal, cast, get_args, get_origin import httpx import pytest @@ -60,6 +60,12 @@ ] +def test_structured_font_types_preserve_flat_literal_shape() -> None: + assert get_origin(PdfStructuredTextKnownFont) is Literal + assert all(isinstance(value, str) for value in get_args(PdfStructuredTextKnownFont)) + assert get_args(PdfStructuredTextFontName) == (PdfStructuredTextKnownFont, str) + + def _dump_payload(model: BaseModel) -> dict[str, Any]: return model.model_dump( mode="json", by_alias=True, exclude_none=True, exclude_unset=True diff --git a/tests/test_create_zugferd_pdf.py b/tests/test_create_zugferd_pdf.py new file mode 100644 index 0000000..ff8f0d6 --- /dev/null +++ b/tests/test_create_zugferd_pdf.py @@ -0,0 +1,1404 @@ +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 isinstance(response, PdfRestFileBasedResponse) + 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, + } + + +COLOR_FIELDS = ( + "text_color", + "muted_text_color", + "accent_color", + "border_color", + "table_header_color", + "table_alternate_color", +) + + +@pytest.mark.parametrize("field", COLOR_FIELDS) +@pytest.mark.parametrize( + ("color", "wire_suffix"), + [ + 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} + + +def test_zugferd_payload_accepts_unlisted_installed_font_name() -> None: + invoice = _make_file("invoice.xml", "application/xml") + payload = _dump( + PdfInvoiceXmlToZugferdPayload, + {"invoice": invoice, "render_options": {"font": "Noto Sans"}}, + ) + assert payload["render_options"] == {"font": "Noto Sans"} + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + 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} + + +VALID_RENDER_BOUNDARIES = [ + pytest.param( + {"decimal_precision": 0}, + {"decimal_precision": 0}, + id="precision-minimum", + ), + pytest.param( + {"decimal_precision": 1}, + {"decimal_precision": 1}, + id="precision-after-minimum", + ), + pytest.param( + {"decimal_precision": 3}, + {"decimal_precision": 3}, + id="precision-before-maximum", + ), + pytest.param( + {"decimal_precision": 4}, + {"decimal_precision": 4}, + id="precision-maximum", + ), + pytest.param( + {"text_color": (0, 0, 0)}, + {"text_color_rgb": [0, 0, 0]}, + id="rgb-minimum", + ), + pytest.param( + {"text_color": (255, 255, 255)}, + {"text_color_rgb": [255, 255, 255]}, + id="rgb-maximum", + ), + pytest.param( + {"text_color": (0, 0, 0, 0)}, + {"text_color_cmyk": [0, 0, 0, 0]}, + id="cmyk-minimum", + ), + pytest.param( + {"text_color": (100, 100, 100, 100)}, + {"text_color_cmyk": [100, 100, 100, 100]}, + id="cmyk-maximum", + ), + pytest.param({"font": "x"}, {"font": "x"}, id="font-minimum"), + pytest.param({"font": "x" * 200}, {"font": "x" * 200}, id="font-maximum"), + pytest.param({"bold_font": "x"}, {"bold_font": "x"}, id="bold-font-minimum"), + pytest.param( + {"bold_font": "x" * 200}, + {"bold_font": "x" * 200}, + id="bold-font-maximum", + ), + pytest.param({"footer_message": ""}, {"footer_message": ""}, id="footer-empty"), + pytest.param( + {"footer_message": "x" * 500}, + {"footer_message": "x" * 500}, + id="footer-maximum", + ), +] + + +@pytest.mark.parametrize(("options", "expected"), VALID_RENDER_BOUNDARIES) +def test_zugferd_payload_accepts_constraint_boundaries( + options: dict[str, Any], expected: dict[str, Any] +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + payload = _dump( + PdfInvoiceXmlToZugferdPayload, + {"invoice": invoice, "render_options": options}, + ) + assert payload["render_options"] == expected + + +@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 request.url.path == "/zugferd-pdf" + 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), + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + counts["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "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 request.url.path == "/zugferd-pdf" + assert json.loads(request.content) == { + "id": str(invoice.id), + "logo_id": str(logo.id), + "output": "zugferd-async", + } + return httpx.Response( + 200, + json={ + "inputId": [str(invoice.id), str(logo.id)], + "outputId": str(output_id), + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + counts["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "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 request.url.path == "/zugferd-pdf" + 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), + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + counts["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "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_preserves_pdf_by_default() -> None: + invoice = _make_file("invoice.xml", "application/xml", prefix=1) + pdf = _make_file("invoice.pdf", "application/pdf", prefix=2) + output_id = PdfRestFileID.generate() + counts = {"post": 0, "get": 0} + + async def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + counts["post"] += 1 + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content) == { + "id": str(invoice.id), + "pdf_id": str(pdf.id), + "regenerate_pdf": False, + "output": "async-preserved", + } + return httpx.Response( + 200, + json={ + "inputId": [str(invoice.id), str(pdf.id)], + "outputId": str(output_id), + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + counts["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "async-preserved.pdf", "application/pdf" + ), + ) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = await client.convert_pdf_to_zugferd_pdf( + pdf, invoice=invoice, output="async-preserved" + ) + + assert counts == {"post": 1, "get": 1} + _assert_response( + response, + input_ids=[invoice.id, pdf.id], + output_id=output_id, + output_name="async-preserved.pdf", + ) + + +def test_convert_pdf_to_zugferd_pdf_allows_regeneration_options() -> None: + invoice = _make_file("invoice.xml", "text/xml", prefix=1) + pdf = _make_file("invoice.pdf", "application/pdf", prefix=2) + logo = _make_file("logo.jpg", "image/jpeg") + output_id = PdfRestFileID.generate() + counts = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + counts["post"] += 1 + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content) == { + "id": str(invoice.id), + "pdf_id": str(pdf.id), + "logo_id": str(logo.id), + "render_options": { + "bold_font": "arialbold", + "border_color_cmyk": [0, 0, 0, 50], + }, + "regenerate_pdf": True, + "output": "replacement-allowed", + } + return httpx.Response( + 200, + json={ + "inputId": [str(invoice.id), str(pdf.id), str(logo.id)], + "outputId": str(output_id), + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + counts["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "replacement-allowed.pdf", "application/pdf" + ), + ) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = client.convert_pdf_to_zugferd_pdf( + pdf, + invoice=invoice, + allow_regeneration=True, + logo=logo, + render_options={"bold_font": "arialbold", "border_color": (0, 0, 0, 50)}, + output="replacement-allowed", + ) + + assert counts == {"post": 1, "get": 1} + _assert_response( + response, + input_ids=[invoice.id, pdf.id, logo.id], + output_id=output_id, + output_name="replacement-allowed.pdf", + ) + + +@pytest.mark.asyncio +async def test_async_convert_pdf_to_zugferd_pdf_allows_regeneration_options() -> None: + invoice = _make_file("invoice.xml", "text/xml", prefix=1) + 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 request.url.path == "/zugferd-pdf" + assert json.loads(request.content) == { + "id": str(invoice.id), + "pdf_id": str(pdf.id), + "logo_id": str(logo.id), + "render_options": { + "bold_font": "arialbold", + "border_color_cmyk": [0, 0, 0, 50], + }, + "regenerate_pdf": True, + "output": "replacement-allowed", + } + return httpx.Response( + 200, + json={ + "inputId": [str(invoice.id), str(pdf.id), str(logo.id)], + "outputId": str(output_id), + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + counts["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + str(output_id), "replacement-allowed.pdf", "application/pdf" + ), + ) + + 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 request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {"font": font} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + 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 request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {"font": font} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + 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} + ) + + +def test_convert_invoice_xml_to_zugferd_pdf_accepts_unlisted_font_name() -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {"font": "Noto Sans"} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options={"font": "Noto Sans"} + ) + + +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_unlisted_font_name() -> ( + None +): + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {"font": "Noto Sans"} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options={"font": "Noto Sans"} + ) + + +CLIENT_COLOR_CASES = [ + pytest.param(field, color, suffix, id=f"{field}-{suffix}") + for field in COLOR_FIELDS + for color, suffix in ( + ((0, 127, 255), "rgb"), + ((0, 25, 50, 100), "cmyk"), + ) +] + +INVALID_CLIENT_COLOR_CASES = [ + pytest.param(field, color, error_match, id=f"{field}-{case_id}") + for field in COLOR_FIELDS + for color, error_match, case_id in ( + ((-1, 0, 0), "greater than or equal to 0", "rgb-low"), + ((0, 256, 0), "less than or equal to 255", "rgb-high"), + ((-1, 0, 0, 0), "greater than or equal to 0", "cmyk-low"), + ((0, 101, 0, 0), "less than or equal to 100", "cmyk-high"), + ) +] + + +@pytest.mark.parametrize(("field", "color", "suffix"), CLIENT_COLOR_CASES) +def test_convert_invoice_xml_to_zugferd_pdf_serializes_every_color_option( + field: str, color: tuple[int, ...], suffix: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == { + f"{field}_{suffix}": list(color) + } + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options) + + +@pytest.mark.parametrize(("field", "color", "suffix"), CLIENT_COLOR_CASES) +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_serializes_every_color_option( + field: str, color: tuple[int, ...], suffix: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == { + f"{field}_{suffix}": list(color) + } + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options) + + +@pytest.mark.parametrize(("field", "color", "error_match"), INVALID_CLIENT_COLOR_CASES) +def test_convert_invoice_xml_to_zugferd_pdf_rejects_every_invalid_color_before_transport( + field: str, color: tuple[int, ...], error_match: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("transport must not run for invalid local input") + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match=error_match), + ): + client.convert_invoice_xml_to_zugferd_pdf(invoice, render_options=options) + + +@pytest.mark.parametrize(("field", "color", "error_match"), INVALID_CLIENT_COLOR_CASES) +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_rejects_every_invalid_color_before_transport( + field: str, color: tuple[int, ...], error_match: str +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("transport must not run for invalid local input") + + options = cast(PdfZugferdRenderOptions, cast(object, {field: color})) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match=error_match): + await client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options=options + ) + + +@pytest.mark.parametrize(("options", "expected"), VALID_RENDER_BOUNDARIES) +def test_convert_invoice_xml_to_zugferd_pdf_accepts_constraint_boundaries( + options: dict[str, Any], expected: dict[str, Any] +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == expected + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + render_options = cast(PdfZugferdRenderOptions, cast(object, options)) + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options=render_options + ) + + +@pytest.mark.parametrize(("options", "expected"), VALID_RENDER_BOUNDARIES) +@pytest.mark.asyncio +async def test_async_convert_invoice_xml_to_zugferd_pdf_accepts_constraint_boundaries( + options: dict[str, Any], expected: dict[str, Any] +) -> None: + invoice = _make_file("invoice.xml", "application/xml") + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == expected + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + render_options = cast(PdfZugferdRenderOptions, cast(object, options)) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await client.convert_invoice_xml_to_zugferd_pdf( + invoice, render_options=render_options + ) + + +CLIENT_RENDER_CASES = [ + *( + pytest.param("logo_corner", value, value, id=f"logo-{value}") + 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", "en", "en", id="language-english"), + pytest.param("label_language", "eng", "en", id="language-english-alias"), + pytest.param("label_language", "de", "de", id="language-german"), + pytest.param("label_language", "deu-DE", "de", id="language-german-alias"), + pytest.param("label_language", "fr", "fr", id="language-french"), + pytest.param("label_language", "fra", "fr", id="language-french-alias"), + pytest.param("date_format", "dd.MM.yyyy", "dd.MM.yyyy", id="date-format"), +] + + +@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 request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {field: expected} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + 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 request.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert json.loads(request.content)["render_options"] == {field: expected} + return httpx.Response(200, json={"inputId": str(invoice.id)}) + + 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-corner", "logo_corner", id="invalid-corner"), + pytest.param("invalid-locale", "locale", id="invalid-locale"), + pytest.param("invalid-currency", "currency_display", id="invalid-currency"), + pytest.param("malformed-color", "greater than or equal to 0", id="malformed-color"), + pytest.param("unsafe-output", "directory separator", id="unsafe-output"), + pytest.param("language-null", "valid string", id="language-null"), + pytest.param("font-empty", "at least 1 character", id="font-empty"), + pytest.param("font-long", "at most 200 characters", id="font-long"), + pytest.param("bold-font-empty", "at least 1 character", id="bold-font-empty"), + pytest.param("bold-font-long", "at most 200 characters", id="bold-font-long"), + pytest.param("footer-long", "at most 500 characters", id="footer-long"), + pytest.param("precision-low", "greater than or equal to 0", id="precision-low"), + pytest.param("precision-high", "less than or equal to 4", id="precision-high"), +] + + +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-corner": (invoice, {"render_options": {"logo_corner": "center"}}), + "invalid-locale": (invoice, {"render_options": {"locale": "en-US"}}), + "invalid-currency": ( + invoice, + {"render_options": {"currency_display": "iso"}}, + ), + "malformed-color": ( + invoice, + {"render_options": {"text_color": (-1, 0, 0)}}, + ), + "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}}), + "bold-font-empty": (invoice, {"render_options": {"bold_font": " "}}), + "bold-font-long": ( + invoice, + {"render_options": {"bold_font": "x" * 201}}, + ), + "footer-long": ( + invoice, + {"render_options": {"footer_message": "x" * 501}}, + ), + "precision-low": ( + invoice, + {"render_options": {"decimal_precision": -1}}, + ), + "precision-high": ( + invoice, + {"render_options": {"decimal_precision": 5}}, + ), + } + 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-empty", "at least 1 item", id="logo-empty"), + pytest.param("logo-extra", "at most 1 item", id="logo-extra"), + pytest.param( + "logo-without-permission", + "allow_regeneration=True", + 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 in {"logo-empty", "logo-extra"}: + logos = { + "logo-empty": [], + "logo-extra": [ + _make_file("one.png", "image/png"), + _make_file("two.jpg", "image/jpeg"), + ], + } + kwargs.update( + allow_regeneration=True, + logo=logos[case], + ) + elif case == "logo-without-permission": + kwargs["logo"] = _make_file("logo.png", "image/png") + else: + 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.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert request.url.params["trace"] == "sync" + assert request.headers["X-Trace"] == "zugferd" + _assert_timeout(request.extensions.get("timeout"), 14.0) + 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.method == "POST" + assert request.url.path == "/zugferd-pdf" + assert request.url.params["trace"] == "async" + assert request.headers["X-Trace"] == "zugferd-async" + _assert_timeout(request.extensions.get("timeout"), 21.0) + 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" },