Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ Use this group for password protection, permissions, signatures, and standards.
[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.
mismatched PDF with a newly rendered invoice. Use
[validate_zugferd_pdf][pdfrest.PdfRestClient.validate_zugferd_pdf] to check an
existing ZUGFeRD or Factur-X package, visible-content match, and independent
PDF/A-3 conformance without modifying the file.

## PDF cleanup and rendering normalization

Expand Down
2 changes: 2 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ to the API guide and API reference for exact method names and options.
- 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
- Validate existing ZUGFeRD or Factur-X PDFs for package, XML, visible-content,
and independent PDF/A-3 conformance with ``validate_zugferd_pdf``

### Extraction and Analysis

Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ supported interpreter matrix.
- `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/validate_zugferd_pdf/validate_zugferd_pdf_example.py` – create and
validate a ZUGFeRD PDF/A-3 invoice, including content-match findings.
- `examples/delete/delete_example.py` – demonstrate file deletion (sync + async
variants).
- `examples/extract_text/extract_pdf_text_example.py` – run `extract_pdf_text`
Expand Down
59 changes: 59 additions & 0 deletions examples/validate_zugferd_pdf/validate_zugferd_pdf_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["pdfrest", "python-dotenv"]
# ///
"""Create and validate a ZUGFeRD PDF/A-3 invoice.

This sample demonstrates how to:

1. Upload the checked-in CII invoice XML resource.
2. Create a ZUGFeRD PDF/A-3 invoice to use as deterministic validation input.
3. Validate its embedded XML, package metadata, visible content, and PDF/A-3
conformance.
4. Print the typed validation status and any findings.

Set ``PDFREST_API_KEY`` and optionally ``PDFREST_BASE_URL`` for a self-hosted
deployment, then run from the repository root with
``uv run examples/validate_zugferd_pdf/validate_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

RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources"
INVOICE_PATH = RESOURCE_DIRECTORY / "zugferd-minimum-invoice.xml"


def validate_zugferd_pdf() -> None:
"""Create a deterministic ZUGFeRD PDF and validate its conformance."""
load_dotenv()
base_url = os.getenv("PDFREST_BASE_URL")

with PdfRestClient(base_url=base_url) as client:
invoice = client.files.create_from_paths(INVOICE_PATH)[0]
created = client.convert_invoice_xml_to_zugferd_pdf(
invoice,
output="zugferd-validation-example",
)
validation = client.validate_zugferd_pdf(created.output_file)

print(f"Status: {validation.status}")
print(f"PDF/A-3 conformant: {validation.pdfa}")
print(f"Content match: {validation.content_match_status}")
print(f"Input ID: {validation.input_id}")
print(f"Input URL: {validation.input_url}")
if validation.message:
print(f"Notice: {validation.message}")
for finding in validation.findings or []:
print(f"{finding.severity}: {finding.code} — {finding.message}")


if __name__ == "__main__": # pragma: no cover - manual example
validate_zugferd_pdf()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pdfrest"
version = "1.5.0"
version = "1.6.0"
description = "Python client library for interacting with the pdfRest API"
readme = { file = "README.md", content-type = "text/markdown" }
license = "Apache-2.0"
Expand Down
91 changes: 91 additions & 0 deletions src/pdfrest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
PdfRestFileBasedResponse,
PdfRestFileID,
PdfRestInfoResponse,
PdfZugferdValidationResponse,
SummarizePdfTextResponse,
TranslatePdfTextFileResponse,
TranslatePdfTextResponse,
Expand Down Expand Up @@ -130,6 +131,7 @@
PdfToWordPayload,
PdfToZugferdPayload,
PdfUnrestrictPayload,
PdfValidateZugferdPayload,
PdfXfaToAcroformsPayload,
PngPdfRestPayload,
SummarizePdfTextPayload,
Expand Down Expand Up @@ -4618,6 +4620,51 @@ def sign_pdf(
timeout=timeout,
)

def validate_zugferd_pdf(
self,
file: PdfRestFile | Sequence[PdfRestFile],
*,
extra_query: Query | None = None,
extra_headers: AnyMapping | None = None,
extra_body: Body | None = None,
timeout: TimeoutTypes | None = None,
) -> PdfZugferdValidationResponse:
"""Validate an existing ZUGFeRD or Factur-X PDF without modifying it.

Checks the embedded invoice XML, attachment relationship, package
metadata, supported profile, visible-content match, and independent
PDF/A-3 conformance.

Args:
file: Uploaded ZUGFeRD or Factur-X PDF resource to validate.
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:
Typed validation status, PDF/A result, content-match status, and
any findings reported by pdfRest.

Raises:
PdfRestError: If request execution fails at the client or API layer.
ValidationError: If local payload validation fails before sending.
"""
validated_payload = PdfValidateZugferdPayload.model_validate({"file": file})
request = self.prepare_request(
"POST",
"/validated-zugferd",
json_body=validated_payload.model_dump(
mode="json", by_alias=True, exclude_none=True, exclude_unset=True
),
extra_query=extra_query,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
)
raw_payload = self._send_request(request)
return PdfZugferdValidationResponse.model_validate(raw_payload)

def convert_invoice_xml_to_zugferd_pdf(
self,
invoice: PdfRestFile | Sequence[PdfRestFile],
Expand Down Expand Up @@ -8268,6 +8315,50 @@ async def sign_pdf(
timeout=timeout,
)

async def validate_zugferd_pdf(
self,
file: PdfRestFile | Sequence[PdfRestFile],
*,
extra_query: Query | None = None,
extra_headers: AnyMapping | None = None,
extra_body: Body | None = None,
timeout: TimeoutTypes | None = None,
) -> PdfZugferdValidationResponse:
"""Asynchronously validate an existing ZUGFeRD or Factur-X PDF.

This is the asynchronous variant of
[PdfRestClient.validate_zugferd_pdf][pdfrest.PdfRestClient.validate_zugferd_pdf].

Args:
file: Uploaded ZUGFeRD or Factur-X PDF resource to validate.
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:
Typed validation status, PDF/A result, content-match status, and
any findings reported by pdfRest.

Raises:
PdfRestError: If request execution fails at the client or API layer.
ValidationError: If local payload validation fails before sending.
"""
validated_payload = PdfValidateZugferdPayload.model_validate({"file": file})
request = self.prepare_request(
"POST",
"/validated-zugferd",
json_body=validated_payload.model_dump(
mode="json", by_alias=True, exclude_none=True, exclude_unset=True
),
extra_query=extra_query,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
)
raw_payload = await self._send_request(request)
return PdfZugferdValidationResponse.model_validate(raw_payload)

async def convert_invoice_xml_to_zugferd_pdf(
self,
invoice: PdfRestFile | Sequence[PdfRestFile],
Expand Down
4 changes: 4 additions & 0 deletions src/pdfrest/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
PdfRestFileBasedResponse,
PdfRestFileID,
PdfRestInfoResponse,
PdfZugferdFinding,
PdfZugferdValidationResponse,
SummarizePdfTextResponse,
TranslatePdfTextFileResponse,
TranslatePdfTextResponse,
Expand All @@ -42,6 +44,8 @@
"PdfRestFileBasedResponse",
"PdfRestFileID",
"PdfRestInfoResponse",
"PdfZugferdFinding",
"PdfZugferdValidationResponse",
"SummarizePdfTextResponse",
"TranslatePdfTextFileResponse",
"TranslatePdfTextResponse",
Expand Down
16 changes: 16 additions & 0 deletions src/pdfrest/models/_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1848,6 +1848,22 @@ def _validate_regeneration_options(self) -> PdfToZugferdPayload:
return self


class PdfValidateZugferdPayload(BaseModel):
"""Adapt an uploaded PDF resource into a ZUGFeRD validation request."""

model_config = ConfigDict(extra="forbid")

file: Annotated[
list[PdfRestFile],
Field(min_length=1, max_length=1, serialization_alias="id"),
BeforeValidator(_ensure_list),
AfterValidator(
_allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
),
PlainSerializer(_serialize_as_first_file_id),
]


class PdfLiteralRedactionModel(BaseModel):
type: Literal["literal"]
value: Annotated[str, Field(min_length=1)]
Expand Down
79 changes: 78 additions & 1 deletion src/pdfrest/models/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import re
import uuid as _uuid
from datetime import date
from typing import Annotated, Any, ClassVar
from typing import Annotated, Any, ClassVar, Literal

from pydantic import (
AliasChoices,
Expand Down Expand Up @@ -45,6 +45,8 @@
"PdfRestFileBasedResponse",
"PdfRestFileID",
"PdfRestInfoResponse",
"PdfZugferdFinding",
"PdfZugferdValidationResponse",
"SummarizePdfTextResponse",
"TranslatePdfTextFileResponse",
"TranslatePdfTextResponse",
Expand Down Expand Up @@ -368,6 +370,81 @@ def output_file(self) -> PdfRestFile:
raise ValueError(msg)


class PdfZugferdFinding(BaseModel):
"""A finding reported while validating a ZUGFeRD or Factur-X PDF."""

code: str
"""Stable finding code reported by the validation tooling."""

severity: Literal["INFO", "WARNING", "BLOCKING", "ERROR"]
"""Severity assigned to the finding by the validation tooling."""

message: str
"""Human-readable summary of the finding."""

field: str | None = None
"""Invoice field associated with the finding, when available."""

status: str | None = None
"""Status detail associated with the finding, when available."""

comparison_reason: Annotated[
str | None,
Field(
validation_alias=AliasChoices("comparison_reason", "comparisonReason"),
pattern=r"^[A-Z][A-Z0-9_]{0,63}$",
),
] = None
"""Machine-readable reason for a visual-comparison result, when available."""

model_config = ConfigDict(extra="allow", frozen=True)


class PdfZugferdValidationResponse(BaseModel):
"""Result returned after validating a ZUGFeRD or Factur-X PDF."""

status: Literal["VALID", "INVALID"]
"""Overall ZUGFeRD package, XML, business-rule, and PDF/A-3 result."""

pdfa: bool
"""Whether the independent PDFChecker result confirms PDF/A-3 conformance."""

content_match_status: Annotated[
Literal[
"NOT_APPLICABLE",
"CONFIRMED",
"UNVERIFIED",
"FAILED",
"ERROR",
"DEMO_REDACTED",
],
Field(
validation_alias=AliasChoices("content_match_status", "contentMatchStatus")
),
]
"""Result of comparing visible PDF content with the embedded invoice XML."""

input_id: Annotated[
PdfRestFileID,
Field(validation_alias=AliasChoices("input_id", "inputId")),
]
"""Identifier of the validated PDF resource."""

input_url: Annotated[
str,
Field(validation_alias=AliasChoices("input_url", "inputUrl")),
]
"""Download URL for the validated PDF resource."""

findings: list[PdfZugferdFinding] | None = None
"""Actionable validation findings and non-blocking warnings, when present."""

message: str | None = None
"""Free-tier notice when detailed validation data has been redacted."""

model_config = ConfigDict(extra="allow", frozen=True)


class PdfRestDeletionResponse(BaseModel):
"""Response returned by the delete tool."""

Expand Down
Loading
Loading