diff --git a/AGENTS.md b/AGENTS.md index 06a36200..0a031a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,10 +220,14 @@ customization (sync + async), validation failures, and multi-file guards. Add a shared validation suite when multiple endpoints rely on the same input rules (e.g., `tests/test_graphic_payload_validation.py`). -- Do not import from private modules (names beginning with an underscore) in - production code. In tests, prefer public modules first; allow private-model - imports only when necessary to validate request serialization or mock - server-facing payload contracts that are not exposed publicly. +- Production code may import private modules and symbols within its own package, + including `pdfrest.client` importing `pdfrest.models._internal`. Do not import + private modules or symbols from external packages; use their supported public + APIs. Another company-owned package is still an external dependency. Keep + package-owned internals outside public exports unless intentionally promoted + to a supported API. In tests, prefer public APIs; access package-owned + internals when needed to verify implementation behavior or request + serialization. ## Testing Guidelines @@ -433,7 +437,16 @@ ## Commit & Pull Request Guidelines -- Follow the `area: summary` convention seen in `pdfassistant-chatbot` (e.g., +- This is a public repository. Keep source, tests, examples, documentation, + commit messages, PR descriptions, and review comments suitable for public + disclosure. Do not name or link private components, implementation classes, + repositories, infrastructure, internal hosts, or unrelated Jira work items. +- Describe service limitations using observable API behavior. For blocked live + coverage, document the affected cases and the steps to re-enable them without + exposing private implementation details. Reference only the PR's primary Jira + work item unless the user explicitly authorizes another reference and confirms + it is suitable for public disclosure. +- Follow the `area: summary` convention (e.g., `client: Add document merge service`). - Name the commit scope after the primary file, directory, or domain object affected by the change, such as `AGENTS`, `pdfrest-client-api`, `client`, diff --git a/docs/api-guide.md b/docs/api-guide.md index aacfafad..60e1d292 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -135,7 +135,12 @@ formats. [convert_email_to_pdf][pdfrest.PdfRestClient.convert_email_to_pdf], [convert_image_to_pdf][pdfrest.PdfRestClient.convert_image_to_pdf], [convert_html_to_pdf][pdfrest.PdfRestClient.convert_html_to_pdf], - [convert_url_to_pdf][pdfrest.PdfRestClient.convert_url_to_pdf] + [convert_url_to_pdf][pdfrest.PdfRestClient.convert_url_to_pdf], + [convert_markdown_to_pdf][pdfrest.PdfRestClient.convert_markdown_to_pdf], + [convert_plain_text_to_pdf][pdfrest.PdfRestClient.convert_plain_text_to_pdf], + [convert_json_to_pdf][pdfrest.PdfRestClient.convert_json_to_pdf], + [convert_xml_to_pdf][pdfrest.PdfRestClient.convert_xml_to_pdf], + [convert_csv_to_pdf][pdfrest.PdfRestClient.convert_csv_to_pdf] - Out of PDF: [convert_to_word][pdfrest.PdfRestClient.convert_to_word], [convert_to_excel][pdfrest.PdfRestClient.convert_to_excel], diff --git a/examples/README.md b/examples/README.md index b3089918..4493fdd8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,9 @@ supported interpreter matrix. - `examples/add_shapes/add_shapes_to_pdf_example.py` – add a styled rectangle and divider line to a PDF with accessibility tagging enabled. +- `examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py` + – convert Markdown, plain text, JSON, XML, and CSV documents to PDF with + format-specific options. - `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_structured_documents/convert_structured_documents_to_pdf_example.py b/examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py new file mode 100644 index 00000000..5db3cbdf --- /dev/null +++ b/examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py @@ -0,0 +1,144 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Convert Markdown, plain text, JSON, XML, and CSV documents to PDF. + +This sample demonstrates how to: + +1. Upload the five deterministic structured documents in ``examples/resources``. +2. Select format-specific conversion options with public typed dictionaries. +3. Generate one PDF from each source through the focused client helpers. +4. Print the returned PDF names, MIME types, sizes, and download URLs. + +Set ``PDFREST_API_KEY``, then run from the repository root with +``uv run examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py``. +All required input files are included in the repository. +""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse +from pdfrest.types import ( + PdfStructuredTextCsvColumn, + PdfStructuredTextMargin, + PdfStructuredTextPageSetup, + PdfStructuredTextStyle, + PdfStructuredTextTableStyle, +) + +RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources" +RESOURCE_PATHS = [ + RESOURCE_DIRECTORY / "structured-document.md", + RESOURCE_DIRECTORY / "structured-document.txt", + RESOURCE_DIRECTORY / "structured-document.json", + RESOURCE_DIRECTORY / "structured-document.xml", + RESOURCE_DIRECTORY / "structured-document.csv", +] + + +def _print_result(label: str, response: PdfRestFileBasedResponse) -> None: + output = response.output_file + print(f"{label}: {output.name}") + print(f" MIME type: {output.type}") + print(f" Size: {output.size} bytes") + print(f" Download URL: {output.url}") + + +def convert_structured_documents() -> None: + """Upload each sample and convert it with its format-specific helper.""" + load_dotenv() + page_setup = PdfStructuredTextPageSetup( + size="Letter", + orientation="portrait", + margin=PdfStructuredTextMargin(top=36, right=36, bottom=36, left=36), + ) + style = PdfStructuredTextStyle( + font="Arial", + text_size=11, + text_color_rgb=(32, 42, 54), + heading_scale=1.4, + ) + table_style = PdfStructuredTextTableStyle( + show_borders=True, + repeat_headers_on_overflow=True, + header_fill_color_rgb=(34, 93, 131), + header_text_color_rgb=(255, 255, 255), + ) + columns = [ + PdfStructuredTextCsvColumn(index=0, text_align="left", width_weight=2), + PdfStructuredTextCsvColumn(index=1, text_align="right", width_weight=1), + ] + + with PdfRestClient() as client: + markdown, plain_text, json_file, xml_file, csv_file = ( + client.files.create_from_paths(RESOURCE_PATHS) + ) + responses = [ + ( + "Markdown", + client.convert_markdown_to_pdf( + markdown, + title="Markdown service summary", + enable_tagging=True, + page_setup=page_setup, + style=style, + table_style=table_style, + output="markdown-summary", + ), + ), + ( + "Plain text", + client.convert_plain_text_to_pdf( + plain_text, + line_handling="reflow", + page_setup=page_setup, + style=style, + output="plain-text-summary", + ), + ), + ( + "JSON", + client.convert_json_to_pdf( + json_file, + data_presentation="hierarchy", + page_setup=page_setup, + style=style, + output="json-summary", + ), + ), + ( + "XML", + client.convert_xml_to_pdf( + xml_file, + data_presentation="hierarchy", + page_setup=page_setup, + style=style, + output="xml-summary", + ), + ), + ( + "CSV", + client.convert_csv_to_pdf( + csv_file, + first_row_is_header=True, + columns=columns, + table_style=table_style, + page_setup=page_setup, + style=style, + output="csv-summary", + ), + ), + ] + + for label, response in responses: + _print_result(label, response) + + +if __name__ == "__main__": # pragma: no cover - manual example + convert_structured_documents() diff --git a/examples/resources/structured-document.csv b/examples/resources/structured-document.csv new file mode 100644 index 00000000..cec13d98 --- /dev/null +++ b/examples/resources/structured-document.csv @@ -0,0 +1,3 @@ +service,availability_percent,status +Document API,99.98,healthy +Conversion API,99.97,healthy diff --git a/examples/resources/structured-document.json b/examples/resources/structured-document.json new file mode 100644 index 00000000..cee3ecf4 --- /dev/null +++ b/examples/resources/structured-document.json @@ -0,0 +1,5 @@ +{ + "quarter": "Q3", + "availability_percent": 99.98, + "priorities": ["accessibility", "document generation"] +} diff --git a/examples/resources/structured-document.md b/examples/resources/structured-document.md new file mode 100644 index 00000000..05d53dba --- /dev/null +++ b/examples/resources/structured-document.md @@ -0,0 +1,10 @@ +**The Importance of Sleep** + +Sleep is often underestimated, but it plays a vital role in maintaining health +and well-being. During sleep, the body repairs tissues, consolidates memories, +and restores energy for the next day. + +Modern life tends to push sleep aside. Busy schedules, late-night work, and +constant access to technology can all reduce rest. + +Prioritizing sleep supports balance and long-term health. diff --git a/examples/resources/structured-document.txt b/examples/resources/structured-document.txt new file mode 100644 index 00000000..e7f2ba64 --- /dev/null +++ b/examples/resources/structured-document.txt @@ -0,0 +1,4 @@ +Quarterly service summary + +Availability improved during the quarter while response times remained stable. +The next release focuses on accessibility and document-generation workflows. diff --git a/examples/resources/structured-document.xml b/examples/resources/structured-document.xml new file mode 100644 index 00000000..dcdb70da --- /dev/null +++ b/examples/resources/structured-document.xml @@ -0,0 +1,6 @@ + + + 99.98 + accessibility + document generation + diff --git a/pyproject.toml b/pyproject.toml index 2bdc5ded..093ab5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdfrest" -version = "1.1.0" +version = "1.2.0" description = "Python client library for interacting with the pdfRest API" readme = {file = "README.md", content-type = "text/markdown"} authors = [ diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index dd1cc72b..d7662c9b 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -76,13 +76,18 @@ from .models._internal import ( BasePdfRestGraphicPayload, BmpPdfRestPayload, + ConvertCsvToPdfPayload, ConvertEmailToPdfPayload, ConvertHtmlToPdfPayload, ConvertImageToPdfPayload, + ConvertJsonToPdfPayload, + ConvertMarkdownToPdfPayload, ConvertOfficeToPdfPayload, + ConvertPlainTextToPdfPayload, ConvertPostscriptToPdfPayload, ConvertToMarkdownPayload, ConvertUrlToPdfPayload, + ConvertXmlToPdfPayload, DeletePayload, ExtractImagesPayload, ExtractTextPayload, @@ -162,6 +167,14 @@ PdfRGBColor, PdfSignatureConfiguration, PdfSignatureCredentials, + PdfStructuredTextCsvColumn, + PdfStructuredTextDataPresentation, + PdfStructuredTextImageSources, + PdfStructuredTextLineHandling, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageSetup, + PdfStructuredTextStyle, + PdfStructuredTextTableStyle, PdfTextColor, PdfXType, PngColorModel, @@ -5184,6 +5197,327 @@ def convert_url_to_pdf( timeout=timeout, ) + def convert_markdown_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + include_unrendered_html: bool | None = None, + image_alt_text: Mapping[str, str] | None = None, + missing_image_alt_text: PdfStructuredTextMissingImageAltText | None = None, + image_sources: PdfStructuredTextImageSources | None = None, + table_style: PdfStructuredTextTableStyle | 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 an uploaded Markdown document to PDF. + + Args: + file: One uploaded ``.md`` or ``.markdown`` file. + title: Optional PDF document title. + language: Optional document language, typically a BCP 47 tag. + enable_tagging: Enable or disable tagged PDF output. + page_setup: Structured page geometry and margins in PDF points. + style: Typography shared by structured document conversions. + include_unrendered_html: Preserve unsupported raw HTML as text. + image_alt_text: Alternate text keyed by Markdown image target. + missing_image_alt_text: Policy for images without alternate text. + image_sources: Uploaded image resources keyed by Markdown target. + table_style: Markdown table presentation settings. + 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 generated PDF. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "include_unrendered_html": include_unrendered_html, + "image_alt_text": image_alt_text, + "missing_image_alt_text": missing_image_alt_text, + "image_sources": image_sources, + "table_style": table_style, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertMarkdownToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_plain_text_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + line_handling: PdfStructuredTextLineHandling | 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 an uploaded plain-text document to PDF. + + Args: + file: One uploaded ``.txt`` file. + title: Optional PDF document title. + language: Optional document language, typically a BCP 47 tag. + enable_tagging: Enable or disable tagged PDF output. + page_setup: Structured page geometry and margins in PDF points. + style: Typography shared by structured document conversions. + line_handling: Reflow source lines or preserve their line breaks. + 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 generated PDF. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "line_handling": line_handling, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertPlainTextToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_json_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + data_presentation: PdfStructuredTextDataPresentation | 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 an uploaded JSON document to PDF. + + Args: + file: One uploaded ``.json`` file. + title: Optional PDF document title. + language: Optional document language, typically a BCP 47 tag. + enable_tagging: Enable or disable tagged PDF output. + page_setup: Structured page geometry and margins in PDF points. + style: Typography shared by structured document conversions. + data_presentation: Preserve JSON source or render its hierarchy. + 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 generated PDF. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "data_presentation": data_presentation, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertJsonToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_xml_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + data_presentation: PdfStructuredTextDataPresentation | 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 an uploaded XML document to PDF. + + Args: + file: One uploaded ``.xml`` file. + title: Optional PDF document title. + language: Optional document language, typically a BCP 47 tag. + enable_tagging: Enable or disable tagged PDF output. + page_setup: Structured page geometry and margins in PDF points. + style: Typography shared by structured document conversions. + data_presentation: Preserve XML source or render its hierarchy. + 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 generated PDF. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "data_presentation": data_presentation, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertXmlToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def convert_csv_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + first_row_is_header: bool | None = None, + delimiter: str | None = None, + columns: Sequence[PdfStructuredTextCsvColumn] | None = None, + table_style: PdfStructuredTextTableStyle | 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 an uploaded CSV document to PDF. + + Args: + file: One uploaded ``.csv`` file. + title: Optional PDF document title. + language: Optional document language, typically a BCP 47 tag. + enable_tagging: Enable or disable tagged PDF output. + page_setup: Structured page geometry and margins in PDF points. + style: Typography shared by structured document conversions. + first_row_is_header: Treat the first CSV row as table headers. + delimiter: One-character CSV field delimiter. + columns: Per-column index, alignment, and width overrides. + table_style: CSV table presentation settings. + 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 generated PDF. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "first_row_is_header": first_row_is_header, + "delimiter": delimiter, + "columns": columns, + "table_style": table_style, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertCsvToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def watermark_pdf_with_text( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -8303,6 +8637,210 @@ async def convert_url_to_pdf( timeout=timeout, ) + async def convert_markdown_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + include_unrendered_html: bool | None = None, + image_alt_text: Mapping[str, str] | None = None, + missing_image_alt_text: PdfStructuredTextMissingImageAltText | None = None, + image_sources: PdfStructuredTextImageSources | None = None, + table_style: PdfStructuredTextTableStyle | 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: + """Asynchronous variant of [PdfRestClient.convert_markdown_to_pdf][pdfrest.PdfRestClient.convert_markdown_to_pdf].""" + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "include_unrendered_html": include_unrendered_html, + "image_alt_text": image_alt_text, + "missing_image_alt_text": missing_image_alt_text, + "image_sources": image_sources, + "table_style": table_style, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return await self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertMarkdownToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_plain_text_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + line_handling: PdfStructuredTextLineHandling | 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: + """Asynchronous variant of [PdfRestClient.convert_plain_text_to_pdf][pdfrest.PdfRestClient.convert_plain_text_to_pdf].""" + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "line_handling": line_handling, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return await self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertPlainTextToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_json_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + data_presentation: PdfStructuredTextDataPresentation | 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: + """Asynchronous variant of [PdfRestClient.convert_json_to_pdf][pdfrest.PdfRestClient.convert_json_to_pdf].""" + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "data_presentation": data_presentation, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return await self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertJsonToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_xml_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + data_presentation: PdfStructuredTextDataPresentation | 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: + """Asynchronous variant of [PdfRestClient.convert_xml_to_pdf][pdfrest.PdfRestClient.convert_xml_to_pdf].""" + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "data_presentation": data_presentation, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return await self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertXmlToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + async def convert_csv_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + title: str | None = None, + language: str | None = None, + enable_tagging: bool | None = None, + page_setup: PdfStructuredTextPageSetup | None = None, + style: PdfStructuredTextStyle | None = None, + first_row_is_header: bool | None = None, + delimiter: str | None = None, + columns: Sequence[PdfStructuredTextCsvColumn] | None = None, + table_style: PdfStructuredTextTableStyle | 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: + """Asynchronous variant of [PdfRestClient.convert_csv_to_pdf][pdfrest.PdfRestClient.convert_csv_to_pdf].""" + payload: dict[str, Any] = { + "files": file, + "title": title, + "language": language, + "enable_tagging": enable_tagging, + "page_setup": page_setup, + "style": style, + "first_row_is_header": first_row_is_header, + "delimiter": delimiter, + "columns": columns, + "table_style": table_style, + "output": output, + } + payload = {key: value for key, value in payload.items() if value is not None} + return await self._post_file_operation( + endpoint="/pdf", + payload=payload, + payload_model=ConvertCsvToPdfPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def watermark_pdf_with_text( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 8a72654e..88a2721a 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -15,10 +15,11 @@ Field, HttpUrl, PlainSerializer, + StringConstraints, model_serializer, model_validator, ) -from pydantic_core import to_json +from pydantic_core import PydanticCustomError, to_json from pdfrest.types.public import PdfRedactionPreset @@ -38,6 +39,13 @@ PdfPageSize, PdfPresetColorProfile, PdfRestriction, + PdfStructuredTextDataPresentation, + PdfStructuredTextFontName, + PdfStructuredTextLineHandling, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageOrientation, + PdfStructuredTextPageSize, + PdfStructuredTextTextAlignment, PdfXType, SummaryFormat, SummaryOutputFormat, @@ -275,6 +283,20 @@ def allowed_mime_types_validator( return allowed_mime_types_validator +def _allowed_file_extensions( + *allowed_extensions: str, error_msg: str +) -> Callable[[list[PdfRestFile]], list[PdfRestFile]]: + normalized_extensions = {extension.casefold() for extension in allowed_extensions} + + def validate_file_extensions(value: list[PdfRestFile]) -> list[PdfRestFile]: + for file in value: + if PurePath(file.name).suffix.casefold() not in normalized_extensions: + raise ValueError(error_msg) + return value + + return validate_file_extensions + + def _int_to_string(value: Any) -> Any: if isinstance(value, int): return str(value) @@ -903,6 +925,444 @@ class ConvertUrlToPdfPayload(BaseModel): ] = None +_STRUCTURED_MARKDOWN_MIME_TYPES = {"text/markdown", "text/x-markdown"} +_STRUCTURED_PLAIN_TEXT_MIME_TYPES = {"text/plain"} +_STRUCTURED_JSON_MIME_TYPES = {"application/json", "text/json"} +_STRUCTURED_XML_MIME_TYPES = {"application/xml", "text/xml"} +_STRUCTURED_CSV_MIME_TYPES = {"text/csv", "application/csv"} +_STRUCTURED_IMAGE_MIME_TYPES = { + "image/gif", + "image/jpeg", + "image/png", + "image/tiff", +} + +_StructuredRgbChannel = Annotated[int, Field(ge=0, le=255)] +_StructuredRgbColor = tuple[ + _StructuredRgbChannel, + _StructuredRgbChannel, + _StructuredRgbChannel, +] +_PositiveStructuredNumber = Annotated[float, Field(gt=0)] +_NonEmptyStructuredString = Annotated[str, Field(min_length=1)] +_TrimmedStructuredString = Annotated[ + str, StringConstraints(strip_whitespace=True, min_length=1) +] + + +def _strip_structured_string(value: Any) -> Any: + return value.strip() if isinstance(value, str) else value + + +_StructuredTextFontName = Annotated[ + PdfStructuredTextFontName, + BeforeValidator(_strip_structured_string), + Field(min_length=1), +] + + +class _StrictStructuredTextModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class _StructuredTextMargin(_StrictStructuredTextModel): + top: Annotated[float | None, Field(ge=0)] = None + right: Annotated[float | None, Field(ge=0)] = None + bottom: Annotated[float | None, Field(ge=0)] = None + left: Annotated[float | None, Field(ge=0)] = None + + +class _StructuredTextPageSetup(_StrictStructuredTextModel): + size: Annotated[ + PdfStructuredTextPageSize | None, + BeforeValidator(_strip_structured_string), + ] = None + width: Annotated[float | None, Field(gt=0)] = None + height: Annotated[float | None, Field(gt=0)] = None + orientation: PdfStructuredTextPageOrientation | None = None + margin: _StructuredTextMargin | None = None + + @model_validator(mode="after") + def _validate_custom_dimensions(self) -> _StructuredTextPageSetup: + if (self.width is None) != (self.height is None): + msg = "page_setup.width and page_setup.height must be provided together." + raise ValueError(msg) + return self + + +class _StructuredTextCellPadding(_StrictStructuredTextModel): + top: Annotated[float | None, Field(ge=0, le=72)] = None + right: Annotated[float | None, Field(ge=0, le=72)] = None + bottom: Annotated[float | None, Field(ge=0, le=72)] = None + left: Annotated[float | None, Field(ge=0, le=72)] = None + + +class _StructuredTextTableStyle(_StrictStructuredTextModel): + column_width_weights: Annotated[ + list[_PositiveStructuredNumber] | None, Field(min_length=1) + ] = None + keep_header_with_first_row: bool | None = None + repeat_headers_on_overflow: bool | None = None + show_borders: bool | None = None + border_width: Annotated[float | None, Field(ge=0, le=12)] = None + border_color_rgb: _StructuredRgbColor | None = None + header_fill_color_rgb: _StructuredRgbColor | None = None + header_text_color_rgb: _StructuredRgbColor | None = None + row_fill_color_rgb: _StructuredRgbColor | None = None + alternate_row_fill_color_rgb: _StructuredRgbColor | None = None + cell_padding: _StructuredTextCellPadding | None = None + + +class _StructuredTextStyle(_StrictStructuredTextModel): + font: _StructuredTextFontName | None = None + heading_font: _StructuredTextFontName | None = None + code_font: _StructuredTextFontName | None = None + cjk_font: _StructuredTextFontName | None = None + fallback_fonts: Annotated[ + list[_StructuredTextFontName] | None, Field(min_length=1) + ] = None + text_size: Annotated[float | None, Field(ge=6, le=72)] = None + text_color_rgb: _StructuredRgbColor | None = None + heading_scale: Annotated[float | None, Field(gt=0, le=4)] = None + + +class _StructuredTextTableEnabledStyle(_StructuredTextStyle): + table: _StructuredTextTableStyle | None = None + + +class _StructuredTextImageSource(_StrictStructuredTextModel): + image_id_index: Annotated[int, Field(ge=0)] + + +class _StructuredTextMarkdownOptions(_StrictStructuredTextModel): + image_alt_text: dict[str, _NonEmptyStructuredString] | None = None + missing_image_alt_text: PdfStructuredTextMissingImageAltText | None = None + image_sources: dict[str, _StructuredTextImageSource] | None = None + + +class _StructuredTextPlainTextOptions(_StrictStructuredTextModel): + line_handling: PdfStructuredTextLineHandling | None = None + + +class _StructuredTextCsvColumn(_StrictStructuredTextModel): + index: Annotated[int, Field(ge=0)] + text_align: PdfStructuredTextTextAlignment | None = None + width_weight: Annotated[float | None, Field(gt=0)] = None + + +class _StructuredTextCsvOptions(_StrictStructuredTextModel): + first_row_is_header: bool | None = None + delimiter: Annotated[str | None, Field(min_length=1, max_length=1)] = None + columns: Annotated[list[_StructuredTextCsvColumn] | None, Field(min_length=1)] = ( + None + ) + + +class _StructuredTextOptionsBase(_StrictStructuredTextModel): + title: _TrimmedStructuredString | None = None + language: _TrimmedStructuredString | None = None + enable_tagging: bool | None = None + page_setup: _StructuredTextPageSetup | None = None + + +class _StructuredTextOptions(_StructuredTextOptionsBase): + style: _StructuredTextStyle | None = None + + +class _StructuredTextMarkdownConversionOptions(_StructuredTextOptionsBase): + include_unrendered_html: bool | None = None + markdown: _StructuredTextMarkdownOptions | None = None + style: _StructuredTextTableEnabledStyle | None = None + + +class _StructuredTextPlainTextConversionOptions(_StructuredTextOptions): + plain_text: _StructuredTextPlainTextOptions | None = None + + +class _StructuredTextDataConversionOptions(_StructuredTextOptions): + data_presentation: PdfStructuredTextDataPresentation | None = None + + +class _StructuredTextCsvConversionOptions(_StructuredTextOptionsBase): + csv: _StructuredTextCsvOptions | None = None + style: _StructuredTextTableEnabledStyle | None = None + + +def _pop_present_options( + normalized: dict[str, Any], keys: Sequence[str] +) -> dict[str, Any]: + return {key: normalized.pop(key) for key in keys if key in normalized} + + +def _merge_structured_text_table_style( + normalized: dict[str, Any], options: dict[str, Any] +) -> None: + table_style = normalized.pop("table_style", None) + if table_style is None: + return + style_value: object = options.get("style") or {} + if not isinstance(style_value, Mapping): + msg = "style must be a mapping when table_style is provided." + error_type = "structured_text_style_type" + raise PydanticCustomError(error_type, msg) + style = cast(Mapping[str, Any], style_value) + options["style"] = {**style, "table": table_style} + + +def _serialize_markdown_image_sources( + image_sources: Mapping[str, Any], normalized: dict[str, Any] +) -> dict[str, dict[str, int]]: + image_ids: list[PdfRestFile] = [] + image_indexes: dict[str, int] = {} + wire_sources: dict[str, dict[str, int]] = {} + for target, image in image_sources.items(): + if not isinstance(image, PdfRestFile): + msg = "image_sources must map Markdown targets to PdfRestFile objects." + error_type = "structured_text_image_source_type" + raise PydanticCustomError(error_type, msg) + image_id = str(image.id) + if image_id not in image_indexes: + image_indexes[image_id] = len(image_ids) + image_ids.append(image) + wire_sources[str(target)] = {"image_id_index": image_indexes[image_id]} + if image_ids: + normalized["image_ids"] = image_ids + return wire_sources + + +def _nest_markdown_options(normalized: dict[str, Any], options: dict[str, Any]) -> None: + options.update(_pop_present_options(normalized, ("include_unrendered_html",))) + markdown_options = _pop_present_options( + normalized, ("image_alt_text", "missing_image_alt_text") + ) + image_sources = normalized.pop("image_sources", None) + if image_sources is not None: + if not isinstance(image_sources, Mapping): + msg = "image_sources must map Markdown targets to PdfRestFile objects." + raise ValueError(msg) + markdown_options["image_sources"] = _serialize_markdown_image_sources( + cast(Mapping[str, Any], image_sources), normalized + ) + if markdown_options: + options["markdown"] = markdown_options + + +def _nest_plain_text_options( + normalized: dict[str, Any], options: dict[str, Any] +) -> None: + plain_text_options = _pop_present_options(normalized, ("line_handling",)) + if plain_text_options: + options["plain_text"] = plain_text_options + + +def _nest_data_options(normalized: dict[str, Any], options: dict[str, Any]) -> None: + options.update(_pop_present_options(normalized, ("data_presentation",))) + + +def _nest_csv_options(normalized: dict[str, Any], options: dict[str, Any]) -> None: + csv_options = _pop_present_options( + normalized, ("first_row_is_header", "delimiter", "columns") + ) + if csv_options: + options["csv"] = csv_options + + +def _nest_structured_text_payload(value: Any, input_format: str) -> Any: + if not isinstance(value, Mapping): + return value + + normalized = dict(cast(Mapping[str, Any], value)) + if "structured_text_options" in normalized: + return normalized + + options = _pop_present_options( + normalized, ("title", "language", "enable_tagging", "page_setup", "style") + ) + _merge_structured_text_table_style(normalized, options) + + option_nesters = { + "markdown": _nest_markdown_options, + "plain_text": _nest_plain_text_options, + "json": _nest_data_options, + "xml": _nest_data_options, + "csv": _nest_csv_options, + } + option_nesters[input_format](normalized, options) + + if options: + normalized["structured_text_options"] = options + return normalized + + +_StructuredMarkdownFiles = Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + *_STRUCTURED_MARKDOWN_MIME_TYPES, + error_msg="Must be a Markdown file.", + ) + ), + AfterValidator( + _allowed_file_extensions( + ".md", ".markdown", error_msg="Must be a .md or .markdown file." + ) + ), + PlainSerializer(_serialize_as_first_file_id), +] +_StructuredPlainTextFiles = Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + *_STRUCTURED_PLAIN_TEXT_MIME_TYPES, + error_msg="Must be a plain text file.", + ) + ), + AfterValidator(_allowed_file_extensions(".txt", error_msg="Must be a .txt file.")), + PlainSerializer(_serialize_as_first_file_id), +] +_StructuredJsonFiles = Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + *_STRUCTURED_JSON_MIME_TYPES, + error_msg="Must be a JSON file.", + ) + ), + AfterValidator( + _allowed_file_extensions(".json", error_msg="Must be a .json file.") + ), + PlainSerializer(_serialize_as_first_file_id), +] +_StructuredXmlFiles = Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + *_STRUCTURED_XML_MIME_TYPES, + error_msg="Must be an XML file.", + ) + ), + AfterValidator(_allowed_file_extensions(".xml", error_msg="Must be an .xml file.")), + PlainSerializer(_serialize_as_first_file_id), +] +_StructuredCsvFiles = Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + *_STRUCTURED_CSV_MIME_TYPES, + error_msg="Must be a CSV file.", + ) + ), + AfterValidator(_allowed_file_extensions(".csv", error_msg="Must be a .csv file.")), + PlainSerializer(_serialize_as_first_file_id), +] + + +class _BaseStructuredTextToPdfPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + +class ConvertMarkdownToPdfPayload(_BaseStructuredTextToPdfPayload): + files: _StructuredMarkdownFiles + structured_text_options: _StructuredTextMarkdownConversionOptions | None = None + image_ids: Annotated[ + list[PdfRestFile] | None, + Field(min_length=1, serialization_alias="image_ids", default=None), + AfterValidator( + _allowed_mime_types( + *_STRUCTURED_IMAGE_MIME_TYPES, + error_msg="Markdown images must be GIF, JPEG, PNG, or TIFF files.", + ) + ), + PlainSerializer(_serialize_file_id_list), + ] = None + + @model_validator(mode="before") + @classmethod + def _build_wire_options(cls, value: Any) -> Any: + return _nest_structured_text_payload(value, "markdown") + + +class ConvertPlainTextToPdfPayload(_BaseStructuredTextToPdfPayload): + files: _StructuredPlainTextFiles + structured_text_options: _StructuredTextPlainTextConversionOptions | None = None + + @model_validator(mode="before") + @classmethod + def _build_wire_options(cls, value: Any) -> Any: + return _nest_structured_text_payload(value, "plain_text") + + +class ConvertJsonToPdfPayload(_BaseStructuredTextToPdfPayload): + files: _StructuredJsonFiles + structured_text_options: _StructuredTextDataConversionOptions | None = None + + @model_validator(mode="before") + @classmethod + def _build_wire_options(cls, value: Any) -> Any: + return _nest_structured_text_payload(value, "json") + + +class ConvertXmlToPdfPayload(_BaseStructuredTextToPdfPayload): + files: _StructuredXmlFiles + structured_text_options: _StructuredTextDataConversionOptions | None = None + + @model_validator(mode="before") + @classmethod + def _build_wire_options(cls, value: Any) -> Any: + return _nest_structured_text_payload(value, "xml") + + +class ConvertCsvToPdfPayload(_BaseStructuredTextToPdfPayload): + files: _StructuredCsvFiles + structured_text_options: _StructuredTextCsvConversionOptions | None = None + + @model_validator(mode="before") + @classmethod + def _build_wire_options(cls, value: Any) -> Any: + return _nest_structured_text_payload(value, "csv") + + class TranslatePdfTextPayload(BaseModel): """Adapt caller options into a pdfRest-ready translate request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 64cdb0e7..6e73600b 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -46,6 +46,21 @@ PdfSignatureDisplay, PdfSignatureLocation, PdfSignaturePoint, + PdfStructuredTextCellPadding, + PdfStructuredTextCsvColumn, + PdfStructuredTextDataPresentation, + PdfStructuredTextFontName, + PdfStructuredTextImageSources, + PdfStructuredTextKnownFont, + PdfStructuredTextLineHandling, + PdfStructuredTextMargin, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageOrientation, + PdfStructuredTextPageSetup, + PdfStructuredTextPageSize, + PdfStructuredTextStyle, + PdfStructuredTextTableStyle, + PdfStructuredTextTextAlignment, PdfTextColor, PdfXType, PngColorModel, @@ -104,6 +119,21 @@ "PdfSignatureDisplay", "PdfSignatureLocation", "PdfSignaturePoint", + "PdfStructuredTextCellPadding", + "PdfStructuredTextCsvColumn", + "PdfStructuredTextDataPresentation", + "PdfStructuredTextFontName", + "PdfStructuredTextImageSources", + "PdfStructuredTextKnownFont", + "PdfStructuredTextLineHandling", + "PdfStructuredTextMargin", + "PdfStructuredTextMissingImageAltText", + "PdfStructuredTextPageOrientation", + "PdfStructuredTextPageSetup", + "PdfStructuredTextPageSize", + "PdfStructuredTextStyle", + "PdfStructuredTextTableStyle", + "PdfStructuredTextTextAlignment", "PdfTextColor", "PdfXType", "PngColorModel", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 9bb02c69..90330870 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, get_args from typing_extensions import Required, TypedDict @@ -58,6 +58,21 @@ "PdfSignatureDisplay", "PdfSignatureLocation", "PdfSignaturePoint", + "PdfStructuredTextCellPadding", + "PdfStructuredTextCsvColumn", + "PdfStructuredTextDataPresentation", + "PdfStructuredTextFontName", + "PdfStructuredTextImageSources", + "PdfStructuredTextKnownFont", + "PdfStructuredTextLineHandling", + "PdfStructuredTextMargin", + "PdfStructuredTextMissingImageAltText", + "PdfStructuredTextPageOrientation", + "PdfStructuredTextPageSetup", + "PdfStructuredTextPageSize", + "PdfStructuredTextStyle", + "PdfStructuredTextTableStyle", + "PdfStructuredTextTextAlignment", "PdfTextColor", "PdfXType", "PngColorModel", @@ -143,6 +158,355 @@ class PdfRedactionInstruction(TypedDict): PdfColor = PdfRGBColor | PdfCMYKColor PdfTextColor = PdfColor +PdfStructuredTextDataPresentation: TypeAlias = Literal["source", "hierarchy"] +"""JSON/XML presentation accepted by structured document conversion helpers. + +Accepted values: + +- `source`: Preserve JSON or XML syntax and indentation. +- `hierarchy`: Render JSON or XML as a readable hierarchy. +""" + +PdfStructuredTextPageOrientation: TypeAlias = Literal["auto", "portrait", "landscape"] +"""Page orientation accepted by structured document conversion helpers. + +Accepted values: + +- `auto`: Let the converter choose an orientation appropriate for the content. +- `portrait`: Use portrait page orientation. +- `landscape`: Use landscape page orientation. +""" + +PdfStructuredTextPageSize: TypeAlias = Literal[ + "Letter", "Legal", "Ledger", "A3", "A4", "A5", "Tabloid" +] +"""Named page size for structured document conversion. + +For a custom size, use ``PdfStructuredTextPageSetup.width`` and ``height``. + +Accepted values: + +- `Letter`: US letter paper, 612 by 792 PDF points. +- `Legal`: US legal paper, 612 by 1008 PDF points. +- `Ledger`: Ledger paper, 1224 by 792 PDF points. +- `A3`: ISO A3 paper, 842 by 1191 PDF points. +- `A4`: ISO A4 paper, 595 by 842 PDF points. +- `A5`: ISO A5 paper, 420 by 595 PDF points. +- `Tabloid`: Tabloid paper, 792 by 1224 PDF points. +""" + +PdfStructuredTextKnownFont: TypeAlias = Literal[ + "andalemono", + "arial", + "arialitalic", + "arialbold", + "arialblack", + "calibri", + "calibriitalic", + "calibribold", + "calibribolditalic", + "cambriaitalic", + "cambriabold", + "cambriabolditalic", + "candara", + "candaraitalic", + "candarabold", + "candarabolditalic", + "cantarellregular", + "cantarelloblique", + "cantarellbold", + "cantarellboldoblique", + "comicsans", + "comicsansbold", + "constantia", + "consolas", + "consolasitalic", + "consolasbold", + "consolasbolditalic", + "constantiaitalic", + "constantiabold", + "constantiabolditalic", + "corbel", + "corbelitalic", + "corbelbold", + "corbelbolditalic", + "courier", + "courieritalic", + "courierbold", + "courierbolditalic", + "dejavu", + "dejavu sans mono oblique", + "dejavu sans mono bold", + "dejavu sans mono bold oblique", + "georgia", + "georgiaitalic", + "georgiabold", + "georgiabolditalic", + "impact", + "tahoma", + "timesnewroman", + "timesnewromanitalic", + "timesnewromanbold", + "timesnewromanbolditalic", + "trebuchet", + "trebuchetitalic", + "trebuchetbold", + "trebuchetbolditalic", + "verdana", + "verdanaitalic", + "verdanabold", + "verdanabolditalic", + "webdings", +] +"""Published pdfRest font names for structured document conversion. + +These are suggestions, not a closed service catalog. An installed font name +outside this list may also be used through ``PdfStructuredTextFontName``. +See the [pdfRest Font List](https://pdfrest.com/documentation/fontlist/). + +Accepted values: + +- `andalemono`: Andale Mono. +- `arial`: Arial. +- `arialitalic`: Arial Italic. +- `arialbold`: Arial Bold. +- `arialblack`: Arial Black. +- `calibri`: Calibri. +- `calibriitalic`: Calibri Italic. +- `calibribold`: Calibri Bold. +- `calibribolditalic`: Calibri Bold Italic. +- `cambriaitalic`: Cambria Italic. +- `cambriabold`: Cambria Bold. +- `cambriabolditalic`: Cambria Bold Italic. +- `candara`: Candara. +- `candaraitalic`: Candara Italic. +- `candarabold`: Candara Bold. +- `candarabolditalic`: Candara Bold Italic. +- `cantarellregular`: Cantarell. +- `cantarelloblique`: Cantarell Oblique. +- `cantarellbold`: Cantarell Bold. +- `cantarellboldoblique`: Cantarell Bold Oblique. +- `comicsans`: Comic Sans. +- `comicsansbold`: Comic Sans Bold. +- `constantia`: Constantia. +- `consolas`: Consolas. +- `consolasitalic`: Consolas Italic. +- `consolasbold`: Consolas Bold. +- `consolasbolditalic`: Consolas Bold Italic. +- `constantiaitalic`: Constantia Italic. +- `constantiabold`: Constantia Bold. +- `constantiabolditalic`: Constantia Bold Italic. +- `corbel`: Corbel. +- `corbelitalic`: Corbel Italic. +- `corbelbold`: Corbel Bold. +- `corbelbolditalic`: Corbel Bold Italic. +- `courier`: Courier New. +- `courieritalic`: Courier New Italic. +- `courierbold`: Courier New Bold. +- `courierbolditalic`: Courier New Bold Italic. +- `dejavu`: DejaVu Sans Mono. +- `dejavu sans mono oblique`: DejaVu Sans Mono Oblique. +- `dejavu sans mono bold`: DejaVu Sans Mono Bold. +- `dejavu sans mono bold oblique`: DejaVu Sans Mono Bold Oblique. +- `georgia`: Georgia. +- `georgiaitalic`: Georgia Italic. +- `georgiabold`: Georgia Bold. +- `georgiabolditalic`: Georgia Bold Italic. +- `impact`: Impact. +- `tahoma`: Tahoma. +- `timesnewroman`: Times New Roman. +- `timesnewromanitalic`: Times New Roman Italic. +- `timesnewromanbold`: Times New Roman Bold. +- `timesnewromanbolditalic`: Times New Roman Bold Italic. +- `trebuchet`: Trebuchet. +- `trebuchetitalic`: Trebuchet Italic. +- `trebuchetbold`: Trebuchet Bold. +- `trebuchetbolditalic`: Trebuchet Bold Italic. +- `verdana`: Verdana. +- `verdanaitalic`: Verdana Italic. +- `verdanabold`: Verdana Bold. +- `verdanabolditalic`: Verdana Bold Italic. +- `webdings`: Webdings. +""" + +PdfStructuredTextFontName: TypeAlias = PdfStructuredTextKnownFont | str +"""A published pdfRest font token or another font name installed on the service. + +Accepted by the ``font``, ``heading_font``, ``code_font``, ``cjk_font``, and +``fallback_fonts`` fields of ``PdfStructuredTextStyle``. The published tokens +provide editor suggestions; the ``str`` branch keeps deployment-specific fonts +available. +""" + +PdfStructuredTextMissingImageAltText: TypeAlias = Literal["warn", "fail", "artifact"] +"""Policy for Markdown images that do not have alternate text. + +Accepted values: + +- `warn`: Continue conversion and report missing alternate text according to + converter behavior. +- `fail`: Reject conversion when an image lacks alternate text. +- `artifact`: Treat an image without alternate text as an artifact. +""" + +PdfStructuredTextLineHandling: TypeAlias = Literal["reflow", "preserve"] +"""Line-break handling accepted by ``convert_plain_text_to_pdf``. + +Accepted values: + +- `reflow`: Reflow plain-text lines to fit the page width. +- `preserve`: Preserve source line breaks. +""" + +PdfStructuredTextTextAlignment: TypeAlias = Literal["left", "center", "right"] +"""CSV column text alignment accepted by ``convert_csv_to_pdf``. + +Accepted values: + +- `left`: Align text to the left of the column. +- `center`: Center text within the column. +- `right`: Align text to the right of the column. +""" + + +class PdfStructuredTextMargin(TypedDict, total=False): + """Per-side page margins for structured document conversion. + + Attributes: + top: Optional top margin in PDF points. Must be at least 0. + right: Optional right margin in PDF points. Must be at least 0. + bottom: Optional bottom margin in PDF points. Must be at least 0. + left: Optional left margin in PDF points. Must be at least 0. + """ + + top: float + right: float + bottom: float + left: float + + +class PdfStructuredTextPageSetup(TypedDict, total=False): + """Page geometry for structured document conversion. + + Attributes: + size: Optional named page size. For custom dimensions, supply ``width`` + and ``height`` instead. + width: Optional custom page width in PDF points. Must be greater than 0 + and supplied together with ``height``. + height: Optional custom page height in PDF points. Must be greater than + 0 and supplied together with ``width``. + orientation: Optional ``auto``, ``portrait``, or ``landscape`` page + orientation. + margin: Optional per-side margins in PDF points. + """ + + size: PdfStructuredTextPageSize + width: float + height: float + orientation: PdfStructuredTextPageOrientation + margin: PdfStructuredTextMargin + + +class PdfStructuredTextCellPadding(TypedDict, total=False): + """Per-side table-cell padding for Markdown and CSV conversion. + + Attributes: + top: Optional top padding in PDF points, from 0 through 72. + right: Optional right padding in PDF points, from 0 through 72. + bottom: Optional bottom padding in PDF points, from 0 through 72. + left: Optional left padding in PDF points, from 0 through 72. + """ + + top: float + right: float + bottom: float + left: float + + +class PdfStructuredTextTableStyle(TypedDict, total=False): + """Table presentation for Markdown and CSV conversion. + + Attributes: + column_width_weights: Optional non-empty relative column-width weights; + every value must be greater than 0. + keep_header_with_first_row: Optional flag to keep the table header with + its first data row during pagination. + repeat_headers_on_overflow: Optional flag to repeat headers on + continuation pages. + show_borders: Optional flag to draw table-cell borders. + border_width: Optional border width in PDF points, from 0 through 12. + border_color_rgb: Optional RGB border color with channels from 0 through + 255. + header_fill_color_rgb: Optional RGB header background color. + header_text_color_rgb: Optional RGB header text color. + row_fill_color_rgb: Optional RGB data-row background color. + alternate_row_fill_color_rgb: Optional RGB alternating-row background + color. + cell_padding: Optional per-side cell padding in PDF points. + """ + + column_width_weights: Sequence[float] + keep_header_with_first_row: bool + repeat_headers_on_overflow: bool + show_borders: bool + border_width: float + border_color_rgb: PdfRGBColor + header_fill_color_rgb: PdfRGBColor + header_text_color_rgb: PdfRGBColor + row_fill_color_rgb: PdfRGBColor + alternate_row_fill_color_rgb: PdfRGBColor + cell_padding: PdfStructuredTextCellPadding + + +class PdfStructuredTextStyle(TypedDict, total=False): + """Typography shared by all structured document conversion helpers. + + The ``PdfStructuredTextFontName`` type suggests the published + ``PdfStructuredTextKnownFont`` values while allowing other names installed + on the service. See the + [pdfRest Font List](https://pdfrest.com/documentation/fontlist/). + + Attributes: + font: Optional non-empty body-text font name installed on the service. + heading_font: Optional non-empty installed heading font name. + code_font: Optional non-empty installed code/preformatted-text font name. + cjk_font: Optional non-empty installed Chinese, Japanese, or Korean font + name. This is a fallback, not a closed catalog. + fallback_fonts: Optional non-empty ordered list of installed fallback + font names. Availability depends on the service deployment. + text_size: Optional body-text size in PDF points, from 6 through 72. + text_color_rgb: Optional RGB body-text color with channels from 0 + through 255. + heading_scale: Optional heading scale greater than 0 and at most 4. + """ + + font: PdfStructuredTextFontName + heading_font: PdfStructuredTextFontName + code_font: PdfStructuredTextFontName + cjk_font: PdfStructuredTextFontName + fallback_fonts: Sequence[PdfStructuredTextFontName] + text_size: float + text_color_rgb: PdfRGBColor + heading_scale: float + + +class PdfStructuredTextCsvColumn(TypedDict, total=False): + """One CSV column presentation override. + + Attributes: + index: Required zero-based CSV column index. + text_align: Optional ``left``, ``center``, or ``right`` alignment. + width_weight: Optional relative width weight greater than 0. + """ + + index: Required[int] + text_align: PdfStructuredTextTextAlignment + width_weight: float + + +PdfStructuredTextImageSources: TypeAlias = Mapping[str, PdfRestFile] +"""Markdown image-target mapping consumed by ``convert_markdown_to_pdf``.""" + PdfContentStructureType: TypeAlias = Literal[ "P", "H", diff --git a/tests/live/test_live_convert_structured_documents_to_pdf.py b/tests/live/test_live_convert_structured_documents_to_pdf.py new file mode 100644 index 00000000..72500b11 --- /dev/null +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -0,0 +1,927 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse +from pdfrest.models._internal import ConvertCsvToPdfPayload, ConvertMarkdownToPdfPayload +from pdfrest.types import ( + PdfStructuredTextCsvColumn, + PdfStructuredTextDataPresentation, + PdfStructuredTextFontName, + PdfStructuredTextLineHandling, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageOrientation, + PdfStructuredTextPageSize, + PdfStructuredTextTextAlignment, +) + +from ..resources import get_test_resource_path + +NAMED_PAGE_SIZES = [ + pytest.param("Letter", id="letter"), + pytest.param(" \tLetter\n ", id="padded-letter"), + pytest.param("Legal", id="legal"), + pytest.param("Ledger", id="ledger"), + pytest.param("A3", id="a3"), + pytest.param("A4", id="a4"), + pytest.param("A5", id="a5"), + pytest.param("Tabloid", id="tabloid"), +] + +WHITESPACE_DELIMITER_SKIP_REASON = ( + "Blocked by service behavior: explicit space/tab CSV delimiters are ignored. " + "Enable after parsing behavior is corrected and deployed." +) + + +@pytest.fixture(scope="module") +def uploaded_structured_documents( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> dict[str, PdfRestFile]: + resources = { + "markdown": get_test_resource_path("structured-document.md"), + "markdown_image": get_test_resource_path("structured-document-with-image.md"), + "markdown_table": get_test_resource_path("structured-document-table.md"), + "markdown_two_images": get_test_resource_path( + "structured-document-with-two-images.md" + ), + "plain_text": get_test_resource_path("structured-document.txt"), + "json": get_test_resource_path("structured-document.json"), + "xml": get_test_resource_path("structured-document.xml"), + "csv": get_test_resource_path("structured-document.csv"), + "image": get_test_resource_path("test.png"), + "second_image": get_test_resource_path("test.jpg"), + } + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded = client.files.create_from_paths(list(resources.values())) + return dict(zip(resources, uploaded, strict=True)) + + +def _assert_structured_pdf( + response: PdfRestFileBasedResponse, + source: PdfRestFile, + output_prefix: str, +) -> None: + assert response.output_files + output = response.output_file + assert output.name.startswith(output_prefix) + assert output.type == "application/pdf" + assert output.size > 0 + assert output.url is not None + assert source.id in response.input_ids + + +def _run_sync( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + invoke: Callable[[PdfRestClient], PdfRestFileBasedResponse], +) -> PdfRestFileBasedResponse: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return invoke(client) + + +async def _run_async( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + invoke: Callable[[AsyncPdfRestClient], Awaitable[PdfRestFileBasedResponse]], +) -> PdfRestFileBasedResponse: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return await invoke(client) + + +@pytest.mark.parametrize("missing_image_alt_text", ["warn", "fail", "artifact"]) +def test_live_convert_markdown_to_pdf_missing_image_alt_text( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + missing_image_alt_text: PdfStructuredTextMissingImageAltText, +) -> None: + source = uploaded_structured_documents["markdown_image"] + image = uploaded_structured_documents["image"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_markdown_to_pdf( + source, + image_sources={"company-logo": image}, + image_alt_text={"company-logo": "Datalogics company logo"}, + missing_image_alt_text=missing_image_alt_text, + output=f"live-markdown-{missing_image_alt_text}", + ), + ) + _assert_structured_pdf(response, source, f"live-markdown-{missing_image_alt_text}") + assert response.input_ids == [source.id, image.id] + + +@pytest.mark.parametrize("line_handling", ["reflow", "preserve"]) +def test_live_convert_plain_text_to_pdf_line_handling( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + line_handling: PdfStructuredTextLineHandling, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + line_handling=line_handling, + output=f"live-plain-text-{line_handling}", + ), + ) + _assert_structured_pdf(response, source, f"live-plain-text-{line_handling}") + + +@pytest.mark.parametrize("orientation", ["auto", "portrait", "landscape"]) +def test_live_convert_plain_text_to_pdf_page_orientation( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + orientation: PdfStructuredTextPageOrientation, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + page_setup={"orientation": orientation}, + output=f"live-plain-text-orientation-{orientation}", + ), + ) + _assert_structured_pdf( + response, source, f"live-plain-text-orientation-{orientation}" + ) + + +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +def test_live_convert_plain_text_to_pdf_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + size: PdfStructuredTextPageSize, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + page_setup={"size": size}, + output=f"live-plain-text-page-size-{size.strip().lower()}", + ), + ) + _assert_structured_pdf( + response, source, f"live-plain-text-page-size-{size.strip().lower()}" + ) + + +def test_live_convert_plain_text_to_pdf_rejects_invalid_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["plain_text"] + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)page.?setup|size|invalid"), + ): + client.convert_plain_text_to_pdf( + source, + extra_body={ + "structured_text_options": {"page_setup": {"size": "Executive"}} + }, + ) + + +@pytest.mark.parametrize( + "font", + [ + pytest.param("arial", id="published-token"), + pytest.param("Noto Sans", id="installed-unlisted-name"), + ], +) +def test_live_convert_plain_text_to_pdf_font_name( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + font: PdfStructuredTextFontName, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, style={"font": font}, output="live-plain-text-font" + ), + ) + _assert_structured_pdf(response, source, "live-plain-text-font") + + +@pytest.mark.parametrize("data_presentation", ["source", "hierarchy"]) +def test_live_convert_json_to_pdf_data_presentation( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + data_presentation: PdfStructuredTextDataPresentation, +) -> None: + source = uploaded_structured_documents["json"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_json_to_pdf( + source, + data_presentation=data_presentation, + output=f"live-json-{data_presentation}", + ), + ) + _assert_structured_pdf(response, source, f"live-json-{data_presentation}") + + +@pytest.mark.parametrize("data_presentation", ["source", "hierarchy"]) +def test_live_convert_xml_to_pdf_data_presentation( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + data_presentation: PdfStructuredTextDataPresentation, +) -> None: + source = uploaded_structured_documents["xml"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_xml_to_pdf( + source, + data_presentation=data_presentation, + output=f"live-xml-{data_presentation}", + ), + ) + _assert_structured_pdf(response, source, f"live-xml-{data_presentation}") + + +@pytest.mark.parametrize("text_align", ["left", "center", "right"]) +def test_live_convert_csv_to_pdf_text_align( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + text_align: PdfStructuredTextTextAlignment, +) -> None: + source = uploaded_structured_documents["csv"] + columns = [ + PdfStructuredTextCsvColumn(index=0, text_align=text_align, width_weight=1) + ] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_csv_to_pdf( + source, + first_row_is_header=True, + columns=columns, + output=f"live-csv-{text_align}", + ), + ) + _assert_structured_pdf(response, source, f"live-csv-{text_align}") + + +@pytest.fixture +def whitespace_csv_document( + tmp_path: Path, + delimiter: str, + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + """Upload input whose two columns use the delimiter under test.""" + path = tmp_path / "whitespace-delimited.csv" + path.write_text(f"Name{delimiter}Value\nAlpha{delimiter}1\n", encoding="utf-8") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + return client.files.create_from_paths(path)[0] + + +@pytest.mark.skip(reason=WHITESPACE_DELIMITER_SKIP_REASON) +@pytest.mark.parametrize( + "delimiter", [pytest.param(" ", id="space"), pytest.param("\t", id="tab")] +) +def test_live_convert_csv_to_pdf_whitespace_delimiter( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + whitespace_csv_document: PdfRestFile, + delimiter: str, +) -> None: + """Selecting the second column requires the delimiter to split the input.""" + source = whitespace_csv_document + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_csv_to_pdf( + source, + delimiter=delimiter, + first_row_is_header=True, + columns=[PdfStructuredTextCsvColumn(index=1, text_align="right")], + output="live-csv-whitespace", + ), + ) + _assert_structured_pdf(response, source, "live-csv-whitespace") + + +@pytest.mark.asyncio +@pytest.mark.skip(reason=WHITESPACE_DELIMITER_SKIP_REASON) +@pytest.mark.parametrize( + "delimiter", [pytest.param(" ", id="space"), pytest.param("\t", id="tab")] +) +async def test_live_async_convert_csv_to_pdf_whitespace_delimiter( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + whitespace_csv_document: PdfRestFile, + delimiter: str, +) -> None: + """Selecting the second column requires the delimiter to split the input.""" + source = whitespace_csv_document + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_csv_to_pdf( + source, + delimiter=delimiter, + first_row_is_header=True, + columns=[PdfStructuredTextCsvColumn(index=1, text_align="right")], + output="live-csv-async-whitespace", + ), + ) + _assert_structured_pdf(response, source, "live-csv-async-whitespace") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing_image_alt_text", ["warn", "fail", "artifact"]) +async def test_live_async_convert_markdown_to_pdf_missing_image_alt_text( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + missing_image_alt_text: PdfStructuredTextMissingImageAltText, +) -> None: + source = uploaded_structured_documents["markdown_image"] + image = uploaded_structured_documents["image"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_markdown_to_pdf( + source, + image_sources={"company-logo": image}, + image_alt_text={"company-logo": "Datalogics company logo"}, + missing_image_alt_text=missing_image_alt_text, + enable_tagging=True, + output=f"live-markdown-async-{missing_image_alt_text}", + ), + ) + _assert_structured_pdf( + response, source, f"live-markdown-async-{missing_image_alt_text}" + ) + assert response.input_ids == [source.id, image.id] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("line_handling", ["reflow", "preserve"]) +async def test_live_async_convert_plain_text_to_pdf_line_handling( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + line_handling: PdfStructuredTextLineHandling, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + line_handling=line_handling, + output=f"live-plain-text-async-{line_handling}", + ), + ) + _assert_structured_pdf(response, source, f"live-plain-text-async-{line_handling}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("orientation", ["auto", "portrait", "landscape"]) +async def test_live_async_convert_plain_text_to_pdf_page_orientation( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + orientation: PdfStructuredTextPageOrientation, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + page_setup={"orientation": orientation}, + output=f"live-plain-text-async-orientation-{orientation}", + ), + ) + _assert_structured_pdf( + response, source, f"live-plain-text-async-orientation-{orientation}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +async def test_live_async_convert_plain_text_to_pdf_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + size: PdfStructuredTextPageSize, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + page_setup={"size": size}, + output=f"live-plain-text-async-page-size-{size.strip().lower()}", + ), + ) + _assert_structured_pdf( + response, source, f"live-plain-text-async-page-size-{size.strip().lower()}" + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_plain_text_to_pdf_rejects_invalid_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["plain_text"] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)page.?setup|size|invalid"): + await client.convert_plain_text_to_pdf( + source, + extra_body={ + "structured_text_options": {"page_setup": {"size": "Executive"}} + }, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "font", + [ + pytest.param("arial", id="published-token"), + pytest.param("Noto Sans", id="installed-unlisted-name"), + ], +) +async def test_live_async_convert_plain_text_to_pdf_font_name( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + font: PdfStructuredTextFontName, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, style={"font": font}, output="live-plain-text-async-font" + ), + ) + _assert_structured_pdf(response, source, "live-plain-text-async-font") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("data_presentation", ["source", "hierarchy"]) +async def test_live_async_convert_json_to_pdf_data_presentation( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + data_presentation: PdfStructuredTextDataPresentation, +) -> None: + source = uploaded_structured_documents["json"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_json_to_pdf( + source, + data_presentation=data_presentation, + output=f"live-json-async-{data_presentation}", + ), + ) + _assert_structured_pdf(response, source, f"live-json-async-{data_presentation}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("data_presentation", ["source", "hierarchy"]) +async def test_live_async_convert_xml_to_pdf_data_presentation( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + data_presentation: PdfStructuredTextDataPresentation, +) -> None: + source = uploaded_structured_documents["xml"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_xml_to_pdf( + source, + data_presentation=data_presentation, + output=f"live-xml-async-{data_presentation}", + ), + ) + _assert_structured_pdf(response, source, f"live-xml-async-{data_presentation}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("text_align", ["left", "center", "right"]) +async def test_live_async_convert_csv_to_pdf_text_align( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + text_align: PdfStructuredTextTextAlignment, +) -> None: + source = uploaded_structured_documents["csv"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_csv_to_pdf( + source, + columns=[ + PdfStructuredTextCsvColumn( + index=0, text_align=text_align, width_weight=1 + ) + ], + delimiter=",", + output=f"live-csv-async-{text_align}", + ), + ) + _assert_structured_pdf(response, source, f"live-csv-async-{text_align}") + + +def test_live_convert_json_to_pdf_rejects_invalid_option( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["json"] + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)data.presentation|invalid|source"), + ): + client.convert_json_to_pdf( + source, + extra_body={"structured_text_options": {"data_presentation": "diorama"}}, + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_json_to_pdf_rejects_invalid_option( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["json"] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises( + PdfRestApiError, match=r"(?i)data.presentation|invalid|source" + ): + await client.convert_json_to_pdf( + source, + extra_body={ + "structured_text_options": {"data_presentation": "diorama"} + }, + ) + + +def _numeric_schema_fields( + node: dict[str, Any], + definitions: dict[str, Any], + path: tuple[str | int, ...] = (), +) -> dict[tuple[str | int, ...], dict[str, Any]]: + """Read numeric constraints from payload schemas, including RGB channels.""" + if "$ref" in node: + node = definitions[node["$ref"].rsplit("/", 1)[-1]] + if "anyOf" in node: + node = next(branch for branch in node["anyOf"] if branch.get("type") != "null") + return _numeric_schema_fields(node, definitions, path) + if node.get("type") in ("number", "integer"): + return {path: node} + fields: dict[tuple[str | int, ...], dict[str, Any]] = {} + for name, child in node.get("properties", {}).items(): + fields.update(_numeric_schema_fields(child, definitions, (*path, name))) + if "items" in node: + fields.update(_numeric_schema_fields(node["items"], definitions, (*path, 0))) + for index, child in enumerate(node.get("prefixItems", [])): + fields.update(_numeric_schema_fields(child, definitions, (*path, index))) + return fields + + +def _numeric_live_cases() -> tuple[list[Any], list[Any]]: + fields: dict[tuple[str | int, ...], dict[str, Any]] = {} + for model in (ConvertMarkdownToPdfPayload, ConvertCsvToPdfPayload): + schema = model.model_json_schema() + fields.update( + _numeric_schema_fields( + schema["properties"]["structured_text_options"], schema["$defs"] + ) + ) + accepted: list[Any] = [] + rejected: list[Any] = [] + for path, field in fields.items(): + step = 1 if field["type"] == "integer" else 0.1 + minimum = field["minimum"] if "minimum" in field else field["exclusiveMinimum"] + valid = [minimum + step] + invalid = [minimum - step] + if "minimum" in field: + valid.insert(0, minimum) + else: + valid.append(minimum + 1) + invalid.append(minimum) + if "maximum" in field: + valid.extend([field["maximum"] - step, field["maximum"]]) + invalid.append(field["maximum"] + step) + label = "-".join(map(str, path)) + for value in valid: + # The service requires usable page area beyond its positive bounds. + layout_rejection = ( + path in (("page_setup", "width"), ("page_setup", "height")) + and value < 180 + ) + accepted.append( + pytest.param(path, value, layout_rejection, id=f"{label}-{value}") + ) + rejected.extend( + pytest.param(path, value, id=f"{label}-{value}") for value in invalid + ) + return accepted, rejected + + +VALID_NUMERIC_LIVE_CASES, INVALID_NUMERIC_LIVE_CASES = _numeric_live_cases() + + +def _numeric_live_options( + path: tuple[str | int, ...], value: float +) -> tuple[str, dict[str, Any], dict[str, Any]]: + """Change one numeric option while keeping related fields valid.""" + options: dict[str, Any] = {} + if path[0] == "csv": + options["csv"] = {"columns": [{"index": 0}]} + if path in (("page_setup", "width"), ("page_setup", "height")): + options["page_setup"] = {"width": 612, "height": 792} + if isinstance(path[-1], int): + if path[-2] == "column_width_weights": + options["style"] = {"table": {"column_width_weights": [1, 1]}} + else: + options["style"] = {} + style = options["style"] + if path[1] == "table": + style["table"] = {} + style = style["table"] + style[path[-2]] = [128, 128, 128] + current: Any = options + for key in path[:-1]: + if isinstance(current, dict): + current = current.setdefault(key, {}) + else: + current = current[key] + current[path[-1]] = value + arguments = dict(options) + if "csv" in arguments: + arguments.update(arguments.pop("csv")) + if "table" in arguments.get("style", {}): + arguments["style"] = dict(arguments["style"]) + arguments["table_style"] = arguments["style"].pop("table") + source_name = "csv" if path[0] == "csv" else "markdown_table" + return source_name, options, arguments + + +@pytest.mark.parametrize( + ("path", "value", "layout_rejection"), VALID_NUMERIC_LIVE_CASES +) +def test_live_structured_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, + layout_rejection: bool, +) -> None: + source_name, _, arguments = _numeric_live_options(path, value) + source = uploaded_structured_documents[source_name] + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + method = ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + ) + if layout_rejection: + with pytest.raises( + PdfRestApiError, match=r"(?i)issue processing|usable width" + ): + method(source, **arguments) + else: + response = method(source, **arguments, output="live-numeric-boundary") + _assert_structured_pdf(response, source, "live-numeric-boundary") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "value", "layout_rejection"), VALID_NUMERIC_LIVE_CASES +) +async def test_live_async_structured_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, + layout_rejection: bool, +) -> None: + source_name, _, arguments = _numeric_live_options(path, value) + source = uploaded_structured_documents[source_name] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + method = ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + ) + if layout_rejection: + with pytest.raises( + PdfRestApiError, match=r"(?i)issue processing|usable width" + ): + await method(source, **arguments) + else: + response = await method( + source, **arguments, output="live-async-numeric-boundary" + ) + _assert_structured_pdf(response, source, "live-async-numeric-boundary") + + +@pytest.mark.parametrize(("path", "value"), INVALID_NUMERIC_LIVE_CASES) +def test_live_structured_rejects_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, +) -> None: + source_name, options, _ = _numeric_live_options(path, value) + with ( + PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)structured_text_options|invalid"), + ): + ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + )( + uploaded_structured_documents[source_name], + extra_body={"structured_text_options": options}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("path", "value"), INVALID_NUMERIC_LIVE_CASES) +async def test_live_async_structured_rejects_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, +) -> None: + source_name, options, _ = _numeric_live_options(path, value) + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + method = ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + ) + with pytest.raises( + PdfRestApiError, match=r"(?i)structured_text_options|invalid" + ): + await method( + uploaded_structured_documents[source_name], + extra_body={"structured_text_options": options}, + ) + + +IMAGE_INDEX_BOUNDARIES = [ + pytest.param(0, True, id="minimum"), + pytest.param(1, True, id="inside"), + pytest.param(-1, False, id="below-minimum"), + pytest.param(2, False, id="outside-uploaded-images"), +] + + +def _image_index_options(image_index: int) -> dict[str, Any]: + return { + "markdown": { + "image_sources": { + "company-logo": {"image_id_index": image_index}, + "second-logo": {"image_id_index": 1 if image_index != 1 else 0}, + }, + "image_alt_text": { + "company-logo": "Company logo", + "second-logo": "Second logo", + }, + } + } + + +@pytest.mark.parametrize(("image_index", "valid"), IMAGE_INDEX_BOUNDARIES) +def test_live_markdown_image_index_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + image_index: int, + valid: bool, +) -> None: + source = uploaded_structured_documents["markdown_two_images"] + image = uploaded_structured_documents["image"] + second_image = uploaded_structured_documents["second_image"] + # Wire indices are internal: extra_body is required to exercise their bounds. + extra_body = { + "image_ids": [str(image.id), str(second_image.id)], + "structured_text_options": _image_index_options(image_index), + } + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + if valid: + response = client.convert_markdown_to_pdf( + source, extra_body=extra_body, output="live-image-index" + ) + _assert_structured_pdf(response, source, "live-image-index") + assert response.input_ids == [source.id, image.id, second_image.id] + else: + with pytest.raises( + PdfRestApiError, + match=r"(?i)image_id_index|image.ids|structured_text_options", + ): + client.convert_markdown_to_pdf(source, extra_body=extra_body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("image_index", "valid"), IMAGE_INDEX_BOUNDARIES) +async def test_live_async_markdown_image_index_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + image_index: int, + valid: bool, +) -> None: + source = uploaded_structured_documents["markdown_two_images"] + image = uploaded_structured_documents["image"] + second_image = uploaded_structured_documents["second_image"] + extra_body = { + "image_ids": [str(image.id), str(second_image.id)], + "structured_text_options": _image_index_options(image_index), + } + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + if valid: + response = await client.convert_markdown_to_pdf( + source, extra_body=extra_body, output="live-async-image-index" + ) + _assert_structured_pdf(response, source, "live-async-image-index") + assert response.input_ids == [source.id, image.id, second_image.id] + else: + with pytest.raises( + PdfRestApiError, + match=r"(?i)image_id_index|image.ids|structured_text_options", + ): + await client.convert_markdown_to_pdf(source, extra_body=extra_body) diff --git a/tests/resources/structured-document-table.md b/tests/resources/structured-document-table.md new file mode 100644 index 00000000..79928e29 --- /dev/null +++ b/tests/resources/structured-document-table.md @@ -0,0 +1,5 @@ +# Boundary test + +| A | B | +| --- | --- | +| 1 | 2 | diff --git a/tests/resources/structured-document-with-image.md b/tests/resources/structured-document-with-image.md new file mode 100644 index 00000000..4b46f10f --- /dev/null +++ b/tests/resources/structured-document-with-image.md @@ -0,0 +1,5 @@ +# Quarterly service summary + +![Company logo](company-logo) + +Availability improved during the quarter while response times remained stable. diff --git a/tests/resources/structured-document-with-two-images.md b/tests/resources/structured-document-with-two-images.md new file mode 100644 index 00000000..8d35f662 --- /dev/null +++ b/tests/resources/structured-document-with-two-images.md @@ -0,0 +1,5 @@ +# Image index boundaries + +![Company logo](company-logo) + +![Second logo](second-logo) diff --git a/tests/resources/structured-document.csv b/tests/resources/structured-document.csv new file mode 100644 index 00000000..cec13d98 --- /dev/null +++ b/tests/resources/structured-document.csv @@ -0,0 +1,3 @@ +service,availability_percent,status +Document API,99.98,healthy +Conversion API,99.97,healthy diff --git a/tests/resources/structured-document.json b/tests/resources/structured-document.json new file mode 100644 index 00000000..beb51856 --- /dev/null +++ b/tests/resources/structured-document.json @@ -0,0 +1,4 @@ +{ + "quarter": "Q3", + "availability_percent": 99.98 +} diff --git a/tests/resources/structured-document.md b/tests/resources/structured-document.md new file mode 100644 index 00000000..62e28e3e --- /dev/null +++ b/tests/resources/structured-document.md @@ -0,0 +1,3 @@ +# Quarterly service summary + +Availability improved during the quarter while response times remained stable. diff --git a/tests/resources/structured-document.txt b/tests/resources/structured-document.txt new file mode 100644 index 00000000..967f7127 --- /dev/null +++ b/tests/resources/structured-document.txt @@ -0,0 +1,3 @@ +Quarterly service summary + +Availability improved during the quarter while response times remained stable. diff --git a/tests/resources/structured-document.xml b/tests/resources/structured-document.xml new file mode 100644 index 00000000..158cf842 --- /dev/null +++ b/tests/resources/structured-document.xml @@ -0,0 +1,4 @@ + + + 99.98 + diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py new file mode 100644 index 00000000..b5e4c72c --- /dev/null +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -0,0 +1,1944 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +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 ( + ConvertCsvToPdfPayload, + ConvertJsonToPdfPayload, + ConvertMarkdownToPdfPayload, + ConvertPlainTextToPdfPayload, + ConvertXmlToPdfPayload, +) +from pdfrest.types import ( + PdfStructuredTextFontName, + PdfStructuredTextKnownFont, + PdfStructuredTextPageSetup, + PdfStructuredTextPageSize, + PdfStructuredTextStyle, +) + +from .convert_to_pdf_test_helpers import make_source_file +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_image_file, +) + +NAMED_PAGE_SIZES = [ + pytest.param("Letter", id="letter"), + pytest.param("Legal", id="legal"), + pytest.param("Ledger", id="ledger"), + pytest.param("A3", id="a3"), + pytest.param("A4", id="a4"), + pytest.param("A5", id="a5"), + pytest.param("Tabloid", id="tabloid"), +] +KNOWN_FONTS = [ + pytest.param(value, id=value.replace(" ", "-")) + for value in get_args(PdfStructuredTextKnownFont) +] + +CSV_DELIMITERS = [ + pytest.param(" ", id="space"), + pytest.param("\t", id="tab"), + pytest.param(",", id="comma"), + pytest.param(";", id="semicolon"), +] +ALT_TEXT_VALUES = [ + pytest.param(" ", id="space"), + pytest.param("\t", id="tab"), + pytest.param(" Company logo ", id="padded-text"), +] + + +def _dump_payload(model: BaseModel) -> dict[str, Any]: + return model.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + + +def _make_format_file(extension: str, mime_type: str) -> PdfRestFile: + return make_source_file( + str(PdfRestFileID.generate(1)), mime_type, f"source{extension}" + ) + + +def _sync_conversion( + monkeypatch: pytest.MonkeyPatch, + source: PdfRestFile, + expected_payload: dict[str, Any], + invoke: Callable[[PdfRestClient], PdfRestFileBasedResponse], + *, + input_ids: list[str] | None = None, +) -> PdfRestFileBasedResponse: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf": + assert json.loads(request.content) == expected_payload + return httpx.Response( + 200, + json={ + "inputId": input_ids or [str(source.id)], + "outputId": output_id, + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "structured.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + return invoke(client) + + +async def _async_conversion( + monkeypatch: pytest.MonkeyPatch, + source: PdfRestFile, + expected_payload: dict[str, Any], + invoke: Callable[[AsyncPdfRestClient], Any], +) -> PdfRestFileBasedResponse: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf": + assert json.loads(request.content) == expected_payload + return httpx.Response( + 200, + json={"inputId": str(source.id), "outputId": output_id}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "structured.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + return await invoke(client) + + +def test_markdown_payload_serializes_options_and_deduplicates_images() -> None: + source = _make_format_file(".md", "text/markdown") + image = make_image_file(str(PdfRestFileID.generate(2)), "image/png", "logo.png") + + payload = _dump_payload( + ConvertMarkdownToPdfPayload.model_validate( + { + "files": source, + "title": "Quarterly service summary", + "language": "en-US", + "enable_tagging": True, + "page_setup": { + "width": 612, + "height": 792, + "orientation": "auto", + "margin": {"top": 36, "right": 36, "bottom": 36, "left": 36}, + }, + "style": { + "font": "Arial", + "fallback_fonts": ["Noto Sans"], + "text_size": 12, + "text_color_rgb": (10, 20, 30), + "heading_scale": 1.5, + }, + "include_unrendered_html": True, + "image_alt_text": {"company-logo": "Company logo"}, + "missing_image_alt_text": "fail", + "image_sources": {"company-logo": image, "footer-logo": image}, + "table_style": { + "column_width_weights": [1, 2], + "show_borders": True, + "border_width": 1, + "border_color_rgb": (1, 2, 3), + "cell_padding": {"top": 5, "right": 6}, + }, + "output": "quarterly-summary", + } + ) + ) + + assert payload == { + "id": str(source.id), + "structured_text_options": { + "title": "Quarterly service summary", + "language": "en-US", + "enable_tagging": True, + "page_setup": { + "width": 612.0, + "height": 792.0, + "orientation": "auto", + "margin": { + "top": 36.0, + "right": 36.0, + "bottom": 36.0, + "left": 36.0, + }, + }, + "style": { + "font": "Arial", + "fallback_fonts": ["Noto Sans"], + "text_size": 12.0, + "text_color_rgb": [10, 20, 30], + "heading_scale": 1.5, + "table": { + "column_width_weights": [1.0, 2.0], + "show_borders": True, + "border_width": 1.0, + "border_color_rgb": [1, 2, 3], + "cell_padding": {"top": 5.0, "right": 6.0}, + }, + }, + "include_unrendered_html": True, + "markdown": { + "image_alt_text": {"company-logo": "Company logo"}, + "missing_image_alt_text": "fail", + "image_sources": { + "company-logo": {"image_id_index": 0}, + "footer-logo": {"image_id_index": 0}, + }, + }, + }, + "image_ids": [str(image.id)], + "output": "quarterly-summary", + } + + +@pytest.mark.parametrize( + ("payload_model", "extension", "mime_type", "options", "expected_options"), + [ + pytest.param( + ConvertPlainTextToPdfPayload, + ".txt", + "text/plain", + {"line_handling": "preserve"}, + {"plain_text": {"line_handling": "preserve"}}, + id="plain-text", + ), + pytest.param( + ConvertJsonToPdfPayload, + ".json", + "application/json", + {"data_presentation": "hierarchy"}, + {"data_presentation": "hierarchy"}, + id="json", + ), + pytest.param( + ConvertXmlToPdfPayload, + ".xml", + "application/xml", + {"data_presentation": "source"}, + {"data_presentation": "source"}, + id="xml", + ), + pytest.param( + ConvertCsvToPdfPayload, + ".csv", + "text/csv", + { + "first_row_is_header": True, + "delimiter": ";", + "columns": [{"index": 0, "text_align": "right", "width_weight": 2}], + "table_style": {"repeat_headers_on_overflow": True}, + }, + { + "style": {"table": {"repeat_headers_on_overflow": True}}, + "csv": { + "first_row_is_header": True, + "delimiter": ";", + "columns": [ + {"index": 0, "text_align": "right", "width_weight": 2.0} + ], + }, + }, + id="csv", + ), + ], +) +def test_format_payloads_serialize_only_applicable_options( + payload_model: type[BaseModel], + extension: str, + mime_type: str, + options: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + source = _make_format_file(extension, mime_type) + payload = _dump_payload(payload_model.model_validate({"files": source, **options})) + assert payload == { + "id": str(source.id), + "structured_text_options": expected_options, + } + + +@pytest.mark.parametrize( + ("payload_model", "valid_file", "invalid_file", "message"), + [ + pytest.param( + ConvertMarkdownToPdfPayload, + (".md", "text/markdown"), + (".txt", "text/plain"), + "Must be a Markdown file", + id="markdown", + ), + pytest.param( + ConvertPlainTextToPdfPayload, + (".txt", "text/plain"), + (".json", "application/json"), + "Must be a plain text file", + id="plain-text", + ), + pytest.param( + ConvertJsonToPdfPayload, + (".json", "application/json"), + (".xml", "application/xml"), + "Must be a JSON file", + id="json", + ), + pytest.param( + ConvertXmlToPdfPayload, + (".xml", "application/xml"), + (".csv", "text/csv"), + "Must be an XML file", + id="xml", + ), + pytest.param( + ConvertCsvToPdfPayload, + (".csv", "text/csv"), + (".md", "text/markdown"), + "Must be a CSV file", + id="csv", + ), + ], +) +def test_format_payloads_reject_other_file_families_and_multiple_files( + payload_model: type[BaseModel], + valid_file: tuple[str, str], + invalid_file: tuple[str, str], + message: str, +) -> None: + valid = _make_format_file(*valid_file) + invalid = _make_format_file(*invalid_file) + with pytest.raises(ValidationError, match=message): + payload_model.model_validate({"files": invalid}) + with pytest.raises( + ValidationError, match="List should have at most 1 item after validation" + ): + payload_model.model_validate({"files": [valid, valid]}) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + pytest.param( + {"page_setup": {"width": 612}}, "must be provided together", id="page" + ), + pytest.param( + {"page_setup": {"width": 0, "height": 792}}, + "greater than 0", + id="page-width-lower-bound", + ), + pytest.param( + {"page_setup": {"margin": {"top": -0.1}}}, + "greater than or equal to 0", + id="margin-lower-bound", + ), + pytest.param( + {"style": {"text_size": 5}}, "greater than or equal to 6", id="text-size" + ), + pytest.param( + {"style": {"heading_scale": 4.1}}, + "less than or equal to 4", + id="heading-scale", + ), + pytest.param( + {"style": {"heading_scale": 0}}, + "greater than 0", + id="heading-scale-lower-bound", + ), + pytest.param( + {"table_style": {"border_width": 12.1}}, + "less than or equal to 12", + id="border", + ), + pytest.param( + {"table_style": {"border_width": -0.1}}, + "greater than or equal to 0", + id="border-lower-bound", + ), + pytest.param( + {"table_style": {"cell_padding": {"top": 73}}}, + "less than or equal to 72", + id="padding", + ), + pytest.param( + {"table_style": {"cell_padding": {"top": -0.1}}}, + "greater than or equal to 0", + id="padding-lower-bound", + ), + pytest.param( + {"style": {"text_color_rgb": (0, 0, 256)}}, + "less than or equal to 255", + id="rgb", + ), + pytest.param( + {"style": {"text_color_rgb": (-1, 0, 0)}}, + "greater than or equal to 0", + id="rgb-lower-bound", + ), + pytest.param( + {"table_style": {"column_width_weights": [0]}}, + "greater than 0", + id="column-width-weight-lower-bound", + ), + pytest.param( + {"image_alt_text": {"logo": ""}}, "at least 1 character", id="alt-text" + ), + ], +) +def test_markdown_payload_rejects_invalid_option_boundaries( + payload: dict[str, Any], message: str +) -> None: + source = _make_format_file(".md", "text/markdown") + with pytest.raises(ValidationError, match=message): + ConvertMarkdownToPdfPayload.model_validate({"files": source, **payload}) + + +def test_markdown_payload_rejects_non_image_resources() -> None: + source = _make_format_file(".md", "text/markdown") + not_image = _make_format_file(".txt", "text/plain") + with pytest.raises( + ValidationError, + match="Markdown images must be GIF, JPEG, PNG, or TIFF files", + ): + ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "image_sources": {"logo": not_image}} + ) + + +def test_structured_payload_uses_filename_extension_as_authoritative_format() -> None: + wrong_extension = _make_format_file(".txt", "text/markdown") + with pytest.raises(ValidationError, match=r"Must be a \.md or \.markdown file"): + ConvertMarkdownToPdfPayload.model_validate({"files": wrong_extension}) + + +@pytest.mark.parametrize("orientation", ["auto", "portrait", "landscape"]) +def test_page_setup_accepts_every_orientation(orientation: str) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "page_setup": {"orientation": orientation}} + ) + ) + assert ( + payload["structured_text_options"]["page_setup"]["orientation"] == orientation + ) + + +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +def test_page_setup_accepts_every_named_size(size: PdfStructuredTextPageSize) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "page_setup": {"size": size}} + ) + ) + assert payload["structured_text_options"]["page_setup"] == {"size": size} + + +def test_page_setup_rejects_unknown_named_size() -> None: + source = _make_format_file(".txt", "text/plain") + with pytest.raises(ValidationError, match=r"page_setup\.size"): + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "page_setup": {"size": "Executive"}} + ) + + +def test_page_setup_accepts_custom_dimensions_instead_of_size_string() -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "page_setup": {"width": 500, "height": 700}} + ) + ) + assert payload["structured_text_options"]["page_setup"] == { + "width": 500.0, + "height": 700.0, + } + + +@pytest.mark.parametrize("font", KNOWN_FONTS) +def test_style_accepts_every_published_font_name( + font: PdfStructuredTextKnownFont, +) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "style": {"font": font}} + ) + ) + assert payload["structured_text_options"]["style"] == {"font": font} + + +def test_style_accepts_open_font_names_in_all_font_fields() -> None: + source = _make_format_file(".txt", "text/plain") + other_font = "Noto Sans CJK JP" + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + { + "files": source, + "style": { + "font": other_font, + "heading_font": other_font, + "code_font": other_font, + "cjk_font": other_font, + "fallback_fonts": ["arial", other_font], + }, + } + ) + ) + assert payload["structured_text_options"]["style"] == { + "font": other_font, + "heading_font": other_font, + "code_font": other_font, + "cjk_font": other_font, + "fallback_fonts": ["arial", other_font], + } + + +@pytest.mark.parametrize( + "style", + [ + pytest.param({"font": ""}, id="body"), + pytest.param({"font": " "}, id="body-whitespace"), + pytest.param({"heading_font": ""}, id="heading"), + pytest.param({"code_font": ""}, id="code"), + pytest.param({"cjk_font": ""}, id="cjk"), + pytest.param({"fallback_fonts": [""]}, id="fallback"), + ], +) +def test_style_rejects_empty_font_names(style: dict[str, Any]) -> None: + source = _make_format_file(".txt", "text/plain") + with pytest.raises(ValidationError, match="at least 1 item"): + ConvertPlainTextToPdfPayload.model_validate({"files": source, "style": style}) + + +@pytest.mark.parametrize("presentation", ["source", "hierarchy"]) +@pytest.mark.parametrize( + ("payload_model", "extension", "mime_type"), + [ + pytest.param(ConvertJsonToPdfPayload, ".json", "application/json", id="json"), + pytest.param(ConvertXmlToPdfPayload, ".xml", "application/xml", id="xml"), + ], +) +def test_data_payloads_accept_every_presentation_literal( + payload_model: type[BaseModel], + extension: str, + mime_type: str, + presentation: str, +) -> None: + source = _make_format_file(extension, mime_type) + payload = _dump_payload( + payload_model.model_validate( + {"files": source, "data_presentation": presentation} + ) + ) + assert payload["structured_text_options"]["data_presentation"] == presentation + + +@pytest.mark.parametrize("policy", ["warn", "fail", "artifact"]) +def test_markdown_payload_accepts_every_missing_alt_text_policy(policy: str) -> None: + source = _make_format_file(".md", "text/markdown") + payload = _dump_payload( + ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "missing_image_alt_text": policy} + ) + ) + assert ( + payload["structured_text_options"]["markdown"]["missing_image_alt_text"] + == policy + ) + + +@pytest.mark.parametrize("line_handling", ["reflow", "preserve"]) +def test_plain_text_payload_accepts_every_line_handling_literal( + line_handling: str, +) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "line_handling": line_handling} + ) + ) + assert ( + payload["structured_text_options"]["plain_text"]["line_handling"] + == line_handling + ) + + +@pytest.mark.parametrize("alignment", ["left", "center", "right"]) +def test_csv_payload_accepts_every_text_alignment_literal(alignment: str) -> None: + source = _make_format_file(".csv", "text/csv") + payload = _dump_payload( + ConvertCsvToPdfPayload.model_validate( + {"files": source, "columns": [{"index": 0, "text_align": alignment}]} + ) + ) + assert ( + payload["structured_text_options"]["csv"]["columns"][0]["text_align"] + == alignment + ) + + +@pytest.mark.parametrize( + "options", + [ + pytest.param({"page_setup": {"width": 0.1, "height": 0.1}}, id="page-min"), + pytest.param({"page_setup": {"margin": {"top": 0}}}, id="margin-min"), + pytest.param({"style": {"text_size": 6}}, id="text-size-min"), + pytest.param({"style": {"text_size": 72}}, id="text-size-max"), + pytest.param({"style": {"heading_scale": 0.1}}, id="heading-scale-min"), + pytest.param({"style": {"heading_scale": 4}}, id="heading-scale-max"), + pytest.param({"table_style": {"border_width": 0}}, id="border-min"), + pytest.param({"table_style": {"border_width": 12}}, id="border-max"), + pytest.param({"table_style": {"cell_padding": {"top": 0}}}, id="padding-min"), + pytest.param({"table_style": {"cell_padding": {"top": 72}}}, id="padding-max"), + pytest.param({"table_style": {"column_width_weights": [0.1]}}, id="weight-min"), + pytest.param({"style": {"text_color_rgb": (0, 255, 0)}}, id="rgb-bounds"), + ], +) +def test_markdown_payload_accepts_numeric_boundaries(options: dict[str, Any]) -> None: + source = _make_format_file(".md", "text/markdown") + ConvertMarkdownToPdfPayload.model_validate({"files": source, **options}) + + +@pytest.mark.parametrize( + ("payload_model", "file_args", "options", "message"), + [ + pytest.param( + ConvertMarkdownToPdfPayload, + (".md", "text/markdown"), + {"missing_image_alt_text": "ignore"}, + "Input should be", + id="markdown-policy", + ), + pytest.param( + ConvertPlainTextToPdfPayload, + (".txt", "text/plain"), + {"line_handling": "wrap"}, + "Input should be", + id="line-handling", + ), + pytest.param( + ConvertJsonToPdfPayload, + (".json", "application/json"), + {"data_presentation": "tree"}, + "Input should be", + id="data-presentation", + ), + pytest.param( + ConvertCsvToPdfPayload, + (".csv", "text/csv"), + {"columns": [{"index": 0, "text_align": "justify"}]}, + "Input should be", + id="text-alignment", + ), + pytest.param( + ConvertCsvToPdfPayload, + (".csv", "text/csv"), + {"delimiter": "||"}, + "at most 1 character", + id="delimiter", + ), + pytest.param( + ConvertCsvToPdfPayload, + (".csv", "text/csv"), + {"delimiter": ""}, + "at least 1 character", + id="delimiter-lower-bound", + ), + pytest.param( + ConvertCsvToPdfPayload, + (".csv", "text/csv"), + {"columns": [{"index": -1}]}, + "greater than or equal to 0", + id="column-index", + ), + pytest.param( + ConvertCsvToPdfPayload, + (".csv", "text/csv"), + {"columns": [{"index": 0, "width_weight": 0}]}, + "greater than 0", + id="column-width-weight-lower-bound", + ), + ], +) +def test_payloads_reject_invalid_literals_and_boundaries( + payload_model: type[BaseModel], + file_args: tuple[str, str], + options: dict[str, Any], + message: str, +) -> None: + source = _make_format_file(*file_args) + with pytest.raises(ValidationError, match=message): + payload_model.model_validate({"files": source, **options}) + + +@pytest.mark.parametrize( + ("method_name", "expected_extension", "expected_mime_type", "expected_message"), + [ + ("convert_markdown_to_pdf", ".md", "text/markdown", "Must be a Markdown file"), + ( + "convert_plain_text_to_pdf", + ".txt", + "text/plain", + "Must be a plain text file", + ), + ("convert_json_to_pdf", ".json", "application/json", "Must be a JSON file"), + ("convert_xml_to_pdf", ".xml", "application/xml", "Must be an XML file"), + ("convert_csv_to_pdf", ".csv", "text/csv", "Must be a CSV file"), + ], +) +@pytest.mark.parametrize( + ("foreign_extension", "foreign_mime_type"), + [ + (".md", "text/markdown"), + (".txt", "text/plain"), + (".json", "application/json"), + (".xml", "application/xml"), + (".csv", "text/csv"), + ], +) +def test_sync_structured_conversions_reject_foreign_files_before_transport( + method_name: str, + expected_extension: str, + expected_mime_type: str, + expected_message: str, + foreign_extension: str, + foreign_mime_type: str, +) -> None: + if (foreign_extension, foreign_mime_type) == ( + expected_extension, + expected_mime_type, + ): + pytest.skip("matching structured document format") + source = _make_format_file(foreign_extension, foreign_mime_type) + + def fail_transport(_: httpx.Request) -> httpx.Response: + pytest.fail("transport should not be called") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(fail_transport) + ) as client, + pytest.raises(ValidationError, match=expected_message), + ): + getattr(client, method_name)(source) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "expected_extension", "expected_mime_type", "expected_message"), + [ + ("convert_markdown_to_pdf", ".md", "text/markdown", "Must be a Markdown file"), + ( + "convert_plain_text_to_pdf", + ".txt", + "text/plain", + "Must be a plain text file", + ), + ("convert_json_to_pdf", ".json", "application/json", "Must be a JSON file"), + ("convert_xml_to_pdf", ".xml", "application/xml", "Must be an XML file"), + ("convert_csv_to_pdf", ".csv", "text/csv", "Must be a CSV file"), + ], +) +@pytest.mark.parametrize( + ("foreign_extension", "foreign_mime_type"), + [ + (".md", "text/markdown"), + (".txt", "text/plain"), + (".json", "application/json"), + (".xml", "application/xml"), + (".csv", "text/csv"), + ], +) +async def test_async_structured_conversions_reject_foreign_files_before_transport( + method_name: str, + expected_extension: str, + expected_mime_type: str, + expected_message: str, + foreign_extension: str, + foreign_mime_type: str, +) -> None: + if (foreign_extension, foreign_mime_type) == ( + expected_extension, + expected_mime_type, + ): + pytest.skip("matching structured document format") + source = _make_format_file(foreign_extension, foreign_mime_type) + + def fail_transport(_: httpx.Request) -> httpx.Response: + pytest.fail("transport should not be called") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(fail_transport) + ) as client: + with pytest.raises(ValidationError, match=expected_message): + await getattr(client, method_name)(source) + + +def test_convert_markdown_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + source = _make_format_file(".md", "text/markdown") + image = make_image_file(str(PdfRestFileID.generate(2))) + payload = _dump_payload( + ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "image_sources": {"logo": image}, "output": "structured"} + ) + ) + response = _sync_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_markdown_to_pdf( + source, image_sources={"logo": image}, output="structured" + ), + input_ids=[str(source.id), str(image.id)], + ) + assert response.output_file.name == "structured.pdf" + assert response.output_file.type == "application/pdf" + assert response.input_ids == [source.id, image.id] + + +def test_convert_plain_text_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "line_handling": "reflow"} + ) + ) + response = _sync_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_plain_text_to_pdf(source, line_handling="reflow"), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +def test_convert_plain_text_to_pdf_accepts_every_named_size( + monkeypatch: pytest.MonkeyPatch, size: PdfStructuredTextPageSize +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"page_setup": {"size": size}}, + } + response = _sync_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf( + source, page_setup={"size": size} + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.parametrize( + "font", [*KNOWN_FONTS, pytest.param("Noto Sans CJK JP", id="installed-name")] +) +def test_convert_plain_text_to_pdf_accepts_known_or_open_font_name( + monkeypatch: pytest.MonkeyPatch, font: PdfStructuredTextFontName +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": {"font": font}}, + } + response = _sync_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style={"font": font}), + ) + assert response.output_file.type == "application/pdf" + + +def test_convert_plain_text_to_pdf_serializes_all_font_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".txt", "text/plain") + style = PdfStructuredTextStyle( + font="arial", + heading_font="Arial Bold", + code_font="courier", + cjk_font="Noto Sans CJK JP", + fallback_fonts=["arial", "Noto Sans"], + ) + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": style}, + } + response = _sync_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style=style), + ) + assert response.output_file.type == "application/pdf" + + +def test_convert_plain_text_to_pdf_rejects_unknown_size_before_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(".txt", "text/plain") + + def fail_transport(request: httpx.Request) -> httpx.Response: + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(fail_transport) + ) as client, + pytest.raises(ValidationError, match=r"page_setup\.size"), + ): + client.convert_plain_text_to_pdf( + source, + page_setup=cast( + PdfStructuredTextPageSetup, cast(object, {"size": "Executive"}) + ), + ) + + +def test_convert_json_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + source = _make_format_file(".json", "application/json") + payload = _dump_payload( + ConvertJsonToPdfPayload.model_validate( + {"files": source, "data_presentation": "hierarchy"} + ) + ) + response = _sync_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_json_to_pdf( + source, data_presentation="hierarchy" + ), + ) + assert response.output_file.type == "application/pdf" + + +def test_convert_xml_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + source = _make_format_file(".xml", "application/xml") + payload = _dump_payload( + ConvertXmlToPdfPayload.model_validate( + {"files": source, "data_presentation": "source"} + ) + ) + response = _sync_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_xml_to_pdf(source, data_presentation="source"), + ) + assert response.output_file.type == "application/pdf" + + +def test_convert_csv_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + source = _make_format_file(".csv", "text/csv") + payload = _dump_payload( + ConvertCsvToPdfPayload.model_validate( + {"files": source, "first_row_is_header": True, "delimiter": ","} + ) + ) + response = _sync_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_csv_to_pdf( + source, first_row_is_header=True, delimiter="," + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_markdown_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".md", "text/markdown") + payload = _dump_payload( + ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "include_unrendered_html": True} + ) + ) + response = await _async_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_markdown_to_pdf( + source, include_unrendered_html=True + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_plain_text_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "line_handling": "preserve"} + ) + ) + response = await _async_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_plain_text_to_pdf( + source, line_handling="preserve" + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +async def test_async_convert_plain_text_to_pdf_accepts_every_named_size( + monkeypatch: pytest.MonkeyPatch, size: PdfStructuredTextPageSize +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"page_setup": {"size": size}}, + } + response = await _async_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf( + source, page_setup={"size": size} + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "font", [*KNOWN_FONTS, pytest.param("Noto Sans CJK JP", id="installed-name")] +) +async def test_async_convert_plain_text_to_pdf_accepts_known_or_open_font_name( + monkeypatch: pytest.MonkeyPatch, font: PdfStructuredTextFontName +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": {"font": font}}, + } + response = await _async_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style={"font": font}), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_plain_text_to_pdf_serializes_all_font_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".txt", "text/plain") + style = PdfStructuredTextStyle( + font="arial", + heading_font="Arial Bold", + code_font="courier", + cjk_font="Noto Sans CJK JP", + fallback_fonts=["arial", "Noto Sans"], + ) + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": style}, + } + response = await _async_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style=style), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_plain_text_to_pdf_rejects_unknown_size_before_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(".txt", "text/plain") + + def fail_transport(request: httpx.Request) -> httpx.Response: + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(fail_transport) + ) as client: + with pytest.raises(ValidationError, match=r"page_setup\.size"): + await client.convert_plain_text_to_pdf( + source, + page_setup=cast( + PdfStructuredTextPageSetup, cast(object, {"size": "Executive"}) + ), + ) + + +@pytest.mark.asyncio +async def test_async_convert_json_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".json", "application/json") + payload = _dump_payload(ConvertJsonToPdfPayload.model_validate({"files": source})) + response = await _async_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_json_to_pdf(source), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_xml_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".xml", "application/xml") + payload = _dump_payload(ConvertXmlToPdfPayload.model_validate({"files": source})) + response = await _async_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_xml_to_pdf(source), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_csv_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".csv", "text/csv") + payload = _dump_payload( + ConvertCsvToPdfPayload.model_validate( + {"files": source, "columns": [{"index": 0, "text_align": "left"}]} + ) + ) + response = await _async_conversion( + monkeypatch, + source, + payload, + lambda client: client.convert_csv_to_pdf( + source, columns=[{"index": 0, "text_align": "left"}] + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.parametrize( + ("method_name", "extension", "mime_type"), + [ + ("convert_markdown_to_pdf", ".md", "text/markdown"), + ("convert_plain_text_to_pdf", ".txt", "text/plain"), + ("convert_json_to_pdf", ".json", "application/json"), + ("convert_xml_to_pdf", ".xml", "application/xml"), + ("convert_csv_to_pdf", ".csv", "text/csv"), + ], +) +def test_structured_conversions_request_customization( + monkeypatch: pytest.MonkeyPatch, + method_name: str, + extension: str, + mime_type: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(extension, mime_type) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + captured_timeout.update(request.extensions["timeout"]) + assert json.loads(request.content)["debug"] is True + return httpx.Response( + 200, json={"inputId": source.id, "outputId": output_id} + ) + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "custom.pdf", "application/pdf"), + ) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + getattr(client, method_name)( + source, + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.5, + ) + assert captured_timeout == { + "connect": pytest.approx(0.5), + "read": pytest.approx(0.5), + "write": pytest.approx(0.5), + "pool": pytest.approx(0.5), + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "extension", "mime_type"), + [ + ("convert_markdown_to_pdf", ".md", "text/markdown"), + ("convert_plain_text_to_pdf", ".txt", "text/plain"), + ("convert_json_to_pdf", ".json", "application/json"), + ("convert_xml_to_pdf", ".xml", "application/xml"), + ("convert_csv_to_pdf", ".csv", "text/csv"), + ], +) +async def test_async_structured_conversions_request_customization( + monkeypatch: pytest.MonkeyPatch, + method_name: str, + extension: str, + mime_type: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(extension, mime_type) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + captured_timeout.update(request.extensions["timeout"]) + assert json.loads(request.content)["debug"] is True + return httpx.Response( + 200, json={"inputId": source.id, "outputId": output_id} + ) + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "custom.pdf", "application/pdf"), + ) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + await getattr(client, method_name)( + source, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.6, + ) + assert captured_timeout == { + "connect": pytest.approx(0.6), + "read": pytest.approx(0.6), + "write": pytest.approx(0.6), + "pool": pytest.approx(0.6), + } + + +@pytest.mark.parametrize("delimiter", CSV_DELIMITERS) +def test_csv_payload_preserves_delimiter(delimiter: str) -> None: + source = _make_format_file(".csv", "text/csv") + payload = ConvertCsvToPdfPayload.model_validate( + {"files": source, "delimiter": delimiter} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": {"csv": {"delimiter": delimiter}}, + } + + +@pytest.mark.parametrize("delimiter", CSV_DELIMITERS) +def test_convert_csv_to_pdf_preserves_delimiter( + monkeypatch: pytest.MonkeyPatch, delimiter: str +) -> None: + source = _make_format_file(".csv", "text/csv") + _sync_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": {"csv": {"delimiter": delimiter}}, + }, + lambda client: client.convert_csv_to_pdf(source, delimiter=delimiter), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delimiter", CSV_DELIMITERS) +async def test_async_convert_csv_to_pdf_preserves_delimiter( + monkeypatch: pytest.MonkeyPatch, delimiter: str +) -> None: + source = _make_format_file(".csv", "text/csv") + await _async_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": {"csv": {"delimiter": delimiter}}, + }, + lambda client: client.convert_csv_to_pdf(source, delimiter=delimiter), + ) + + +@pytest.mark.parametrize("alt_text", ALT_TEXT_VALUES) +def test_markdown_payload_preserves_alt_text(alt_text: str) -> None: + source = _make_format_file(".md", "text/markdown") + payload = ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "image_alt_text": {"logo": alt_text}} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": {"markdown": {"image_alt_text": {"logo": alt_text}}}, + } + + +@pytest.mark.parametrize("alt_text", ALT_TEXT_VALUES) +def test_convert_markdown_to_pdf_preserves_alt_text( + monkeypatch: pytest.MonkeyPatch, alt_text: str +) -> None: + source = _make_format_file(".md", "text/markdown") + _sync_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": { + "markdown": {"image_alt_text": {"logo": alt_text}} + }, + }, + lambda client: client.convert_markdown_to_pdf( + source, image_alt_text={"logo": alt_text} + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("alt_text", ALT_TEXT_VALUES) +async def test_async_convert_markdown_to_pdf_preserves_alt_text( + monkeypatch: pytest.MonkeyPatch, alt_text: str +) -> None: + source = _make_format_file(".md", "text/markdown") + await _async_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": { + "markdown": {"image_alt_text": {"logo": alt_text}} + }, + }, + lambda client: client.convert_markdown_to_pdf( + source, image_alt_text={"logo": alt_text} + ), + ) + + +TRIMMED_TEXT_OPTIONS = [ + pytest.param( + {"page_setup": {"size": " \tLetter\n "}}, + {"page_setup": {"size": "Letter"}}, + id="page-size", + ), + pytest.param({"title": " Report "}, {"title": "Report"}, id="title"), + pytest.param({"language": " en-US "}, {"language": "en-US"}, id="language"), + *[ + pytest.param( + {"style": {field: " Noto Sans "}}, + {"style": {field: "Noto Sans"}}, + id=field, + ) + for field in ("font", "heading_font", "code_font", "cjk_font") + ], + pytest.param( + {"style": {"fallback_fonts": [" arial ", " Noto Sans "]}}, + {"style": {"fallback_fonts": ["arial", "Noto Sans"]}}, + id="fallback-fonts", + ), +] + + +@pytest.mark.parametrize(("options", "expected_options"), TRIMMED_TEXT_OPTIONS) +def test_payload_trims_api_normalized_text( + options: dict[str, Any], expected_options: dict[str, Any] +) -> None: + source = _make_format_file(".txt", "text/plain") + payload = ConvertPlainTextToPdfPayload.model_validate({"files": source, **options}) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": expected_options, + } + + +@pytest.mark.parametrize(("options", "expected_options"), TRIMMED_TEXT_OPTIONS) +def test_convert_plain_text_to_pdf_trims_api_normalized_text( + monkeypatch: pytest.MonkeyPatch, + options: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + source = _make_format_file(".txt", "text/plain") + _sync_conversion( + monkeypatch, + source, + {"id": str(source.id), "structured_text_options": expected_options}, + lambda client: client.convert_plain_text_to_pdf(source, **options), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("options", "expected_options"), TRIMMED_TEXT_OPTIONS) +async def test_async_convert_plain_text_to_pdf_trims_api_normalized_text( + monkeypatch: pytest.MonkeyPatch, + options: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + source = _make_format_file(".txt", "text/plain") + await _async_conversion( + monkeypatch, + source, + {"id": str(source.id), "structured_text_options": expected_options}, + lambda client: client.convert_plain_text_to_pdf(source, **options), + ) + + +INVALID_CSV_DELIMITERS = [ + pytest.param("", "at least 1 character", id="empty"), + pytest.param(", ", "at most 1 character", id="trailing-space"), + pytest.param(" ,", "at most 1 character", id="leading-space"), + pytest.param(" \t", "at most 1 character", id="two-whitespace-characters"), +] + + +@pytest.mark.parametrize(("delimiter", "message"), INVALID_CSV_DELIMITERS) +def test_csv_payload_rejects_delimiter_without_trimming( + delimiter: str, message: str +) -> None: + source = _make_format_file(".csv", "text/csv") + with pytest.raises(ValidationError, match=rf"(?s)delimiter.*{message}"): + ConvertCsvToPdfPayload.model_validate({"files": source, "delimiter": delimiter}) + + +@pytest.fixture +def validation_transport() -> httpx.MockTransport: + def fail_transport(_: httpx.Request) -> httpx.Response: + pytest.fail("invalid options must not reach transport") + + return httpx.MockTransport(fail_transport) + + +@pytest.mark.parametrize(("delimiter", "message"), INVALID_CSV_DELIMITERS) +def test_convert_csv_to_pdf_rejects_delimiter_before_transport( + delimiter: str, message: str, validation_transport: httpx.MockTransport +) -> None: + source = _make_format_file(".csv", "text/csv") + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=validation_transport) as client, + pytest.raises(ValidationError, match=rf"(?s)delimiter.*{message}"), + ): + client.convert_csv_to_pdf(source, delimiter=delimiter) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("delimiter", "message"), INVALID_CSV_DELIMITERS) +async def test_async_convert_csv_to_pdf_rejects_delimiter_before_transport( + delimiter: str, message: str, validation_transport: httpx.MockTransport +) -> None: + source = _make_format_file(".csv", "text/csv") + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=validation_transport + ) as client: + with pytest.raises(ValidationError, match=rf"(?s)delimiter.*{message}"): + await client.convert_csv_to_pdf(source, delimiter=delimiter) + + +def _nested_option(path: tuple[str, ...], value: Any) -> dict[str, Any]: + options: dict[str, Any] = {path[-1]: value} + for key in reversed(path[:-1]): + options = {key: options} + return options + + +def _numeric_options(path: tuple[str, ...], value: float) -> dict[str, Any]: + if path[0] == "columns": + return {"columns": [{"index": 0, path[-1]: value}]} + if path[-1] == "column_width_weights": + return _nested_option(path, [value]) + if path in (("page_setup", "width"), ("page_setup", "height")): + return {"page_setup": {"width": 612, "height": 792, path[-1]: value}} + return _nested_option(path, value) + + +NUMERIC_BOUNDARIES: list[ + tuple[tuple[str, ...], tuple[float, ...], tuple[float, ...], str] +] = [ + (("page_setup", "width"), (0.1, 1), (0, -0.1), "greater than 0"), + (("page_setup", "height"), (0.1, 1), (0, -0.1), "greater than 0"), + *[ + ( + ("page_setup", "margin", side), + (0, 0.1), + (-0.1,), + "greater than or equal to 0", + ) + for side in ("top", "right", "bottom", "left") + ], + ( + ("style", "text_size"), + (6, 7, 71, 72), + (5, 73), + "greater than or equal to 6|less than or equal to 72", + ), + ( + ("style", "heading_scale"), + (0.1, 3.9, 4), + (0, -0.1, 4.1), + "greater than 0|less than or equal to 4", + ), + ( + ("table_style", "border_width"), + (0, 0.1, 11.9, 12), + (-0.1, 12.1), + "greater than or equal to 0|less than or equal to 12", + ), + *[ + ( + ("table_style", "cell_padding", side), + (0, 0.1, 71.9, 72), + (-0.1, 72.1), + "greater than or equal to 0|less than or equal to 72", + ) + for side in ("top", "right", "bottom", "left") + ], + (("table_style", "column_width_weights"), (0.1, 1), (0, -0.1), "greater than 0"), + (("columns", "index"), (0, 1), (-1,), "greater than or equal to 0"), + (("columns", "width_weight"), (0.1, 1), (0, -0.1), "greater than 0"), +] + +VALID_OPTION_BOUNDARIES = [ + pytest.param(_numeric_options(path, value), id=f"{'-'.join(path)}-{value}") + for path, accepted, _, _ in NUMERIC_BOUNDARIES + for value in accepted +] +INVALID_OPTION_BOUNDARIES = [ + pytest.param( + _numeric_options(path, value), + rf"(?s){path[-1]}.*(?:{message})", + id=f"{'-'.join(path)}-{value}", + ) + for path, _, rejected, message in NUMERIC_BOUNDARIES + for value in rejected +] + +RGB_FIELDS = [ + ("style", "text_color_rgb"), + *[ + ("table_style", field) + for field in ( + "border_color_rgb", + "header_fill_color_rgb", + "header_text_color_rgb", + "row_fill_color_rgb", + "alternate_row_fill_color_rgb", + ) + ], +] +VALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option( + path, [value if index == channel else 128 for index in range(3)] + ), + id=f"{'-'.join(path)}-channel-{channel}-{value}", + ) + for path in RGB_FIELDS + for channel in range(3) + for value in (0, 1, 254, 255) +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option( + path, [value if index == channel else 128 for index in range(3)] + ), + rf"(?s){path[-1]}\.{channel}.*(?:greater than or equal to 0|less than or equal to 255)", + id=f"{'-'.join(path)}-channel-{channel}-{value}", + ) + for path in RGB_FIELDS + for channel in range(3) + for value in (-1, 256) +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option(path, [128] * length), + rf"(?s){path[-1]}.*(?:Field required|at most 3 items)", + id=f"{'-'.join(path)}-{length}-channels", + ) + for path in RGB_FIELDS + for length in (2, 4) +) + +COLLECTION_BOUNDARIES = [ + (("table_style", "column_width_weights"), 1), + (("style", "fallback_fonts"), "Arial"), + (("columns",), {"index": 0}), +] +VALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option(path, [item] * length), + id=f"{'-'.join(path)}-{length}-items", + ) + for path, item in COLLECTION_BOUNDARIES + for length in (1, 2) +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option(path, []), + rf"(?s){path[-1]}.*at least 1 item", + id=f"{'-'.join(path)}-empty", + ) + for path, _ in COLLECTION_BOUNDARIES +) + +TEXT_BOUNDARIES = [ + ("title",), + ("language",), + *[ + ("style", field) + for field in ("font", "heading_font", "code_font", "cjk_font", "fallback_fonts") + ], + ("image_alt_text",), +] + + +def _text_options(path: tuple[str, ...], text: str) -> dict[str, Any]: + value: Any = text + if path[-1] == "fallback_fonts": + value = [text] + elif path[-1] == "image_alt_text": + value = {"logo": text} + return _nested_option(path, value) + + +VALID_OPTION_BOUNDARIES.extend( + pytest.param( + _text_options(path, text), id=f"{'-'.join(path)}-{len(text)}-characters" + ) + for path in TEXT_BOUNDARIES + for text in ("A", "AB") +) +VALID_OPTION_BOUNDARIES.extend( + pytest.param({"output": output}, id=f"output-{len(output)}-characters") + for output in ("A", "AB") +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _text_options(path, text), + rf"(?s){path[-1]}.*at least 1 (?:character|item)", + id=f"{'-'.join(path)}-{'empty' if not text else 'whitespace'}", + ) + for path in TEXT_BOUNDARIES + for text in (("",) if path[-1] == "image_alt_text" else ("", " \t ")) +) +INVALID_OPTION_BOUNDARIES.append( + pytest.param({"output": ""}, r"(?s)output.*at least 1 character", id="output-empty") +) + + +def _boundary_source( + options: dict[str, Any], +) -> tuple[PdfRestFile, type[BaseModel], str]: + if "columns" in options: + return ( + _make_format_file(".csv", "text/csv"), + ConvertCsvToPdfPayload, + "convert_csv_to_pdf", + ) + return ( + _make_format_file(".md", "text/markdown"), + ConvertMarkdownToPdfPayload, + "convert_markdown_to_pdf", + ) + + +def _expected_boundary_payload( + source: PdfRestFile, options: dict[str, Any] +) -> dict[str, Any]: + if "output" in options: + return {"id": str(source.id), "output": options["output"]} + wire_options = dict(options) + if "table_style" in wire_options: + wire_options["style"] = {"table": wire_options.pop("table_style")} + elif "columns" in wire_options: + wire_options = {"csv": wire_options} + elif "image_alt_text" in wire_options: + wire_options = {"markdown": wire_options} + return {"id": str(source.id), "structured_text_options": wire_options} + + +@pytest.mark.parametrize("options", VALID_OPTION_BOUNDARIES) +def test_payload_accepts_declared_constraint_boundaries( + options: dict[str, Any], +) -> None: + source, payload_model, _ = _boundary_source(options) + payload = payload_model.model_validate({"files": source, **options}) + assert _dump_payload(payload) == _expected_boundary_payload(source, options) + + +@pytest.mark.parametrize("options", VALID_OPTION_BOUNDARIES) +def test_sync_accepts_declared_constraint_boundaries( + monkeypatch: pytest.MonkeyPatch, options: dict[str, Any] +) -> None: + source, _, method = _boundary_source(options) + expected = _expected_boundary_payload(source, options) + _sync_conversion( + monkeypatch, + source, + expected, + lambda client: getattr(client, method)(source, **options), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("options", VALID_OPTION_BOUNDARIES) +async def test_async_accepts_declared_constraint_boundaries( + monkeypatch: pytest.MonkeyPatch, options: dict[str, Any] +) -> None: + source, _, method = _boundary_source(options) + expected = _expected_boundary_payload(source, options) + await _async_conversion( + monkeypatch, + source, + expected, + lambda client: getattr(client, method)(source, **options), + ) + + +@pytest.mark.parametrize(("options", "message"), INVALID_OPTION_BOUNDARIES) +def test_payload_rejects_declared_constraint_boundaries( + options: dict[str, Any], message: str +) -> None: + source, payload_model, _ = _boundary_source(options) + with pytest.raises(ValidationError, match=message): + payload_model.model_validate({"files": source, **options}) + + +@pytest.mark.parametrize(("options", "message"), INVALID_OPTION_BOUNDARIES) +def test_sync_rejects_declared_constraint_boundaries_before_transport( + options: dict[str, Any], + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source, _, method = _boundary_source(options) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=validation_transport) as client, + pytest.raises(ValidationError, match=message), + ): + getattr(client, method)(source, **options) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("options", "message"), INVALID_OPTION_BOUNDARIES) +async def test_async_rejects_declared_constraint_boundaries_before_transport( + options: dict[str, Any], + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source, _, method = _boundary_source(options) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=validation_transport + ) as client: + with pytest.raises(ValidationError, match=message): + await getattr(client, method)(source, **options) + + +STRUCTURED_FORMATS = [ + pytest.param( + ConvertMarkdownToPdfPayload, + "convert_markdown_to_pdf", + ".md", + "text/markdown", + id="markdown", + ), + pytest.param( + ConvertPlainTextToPdfPayload, + "convert_plain_text_to_pdf", + ".txt", + "text/plain", + id="plain-text", + ), + pytest.param( + ConvertJsonToPdfPayload, + "convert_json_to_pdf", + ".json", + "application/json", + id="json", + ), + pytest.param( + ConvertXmlToPdfPayload, + "convert_xml_to_pdf", + ".xml", + "application/xml", + id="xml", + ), + pytest.param( + ConvertCsvToPdfPayload, + "convert_csv_to_pdf", + ".csv", + "text/csv", + id="csv", + ), +] +INVALID_SOURCE_COUNTS = [ + pytest.param(0, "at least 1 item", id="empty"), + pytest.param(2, "at most 1 item", id="multiple"), +] + + +@pytest.mark.parametrize( + ("payload_model", "_method", "extension", "mime_type"), STRUCTURED_FORMATS +) +@pytest.mark.parametrize(("count", "message"), INVALID_SOURCE_COUNTS) +def test_payload_rejects_source_cardinality_bounds( + payload_model: type[BaseModel], + _method: str, + extension: str, + mime_type: str, + count: int, + message: str, +) -> None: + source = _make_format_file(extension, mime_type) + with pytest.raises(ValidationError, match=rf"(?s)files.*{message}"): + payload_model.model_validate({"files": [source] * count}) + + +@pytest.mark.parametrize( + ("_payload_model", "method", "extension", "mime_type"), STRUCTURED_FORMATS +) +@pytest.mark.parametrize(("count", "message"), INVALID_SOURCE_COUNTS) +def test_sync_rejects_source_cardinality_bounds_before_transport( + _payload_model: type[BaseModel], + method: str, + extension: str, + mime_type: str, + count: int, + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source = _make_format_file(extension, mime_type) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=validation_transport) as client, + pytest.raises(ValidationError, match=rf"(?s)files.*{message}"), + ): + getattr(client, method)([source] * count) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("_payload_model", "method", "extension", "mime_type"), STRUCTURED_FORMATS +) +@pytest.mark.parametrize(("count", "message"), INVALID_SOURCE_COUNTS) +async def test_async_rejects_source_cardinality_bounds_before_transport( + _payload_model: type[BaseModel], + method: str, + extension: str, + mime_type: str, + count: int, + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source = _make_format_file(extension, mime_type) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=validation_transport + ) as client: + with pytest.raises(ValidationError, match=rf"(?s)files.*{message}"): + await getattr(client, method)([source] * count) + + +@pytest.mark.parametrize( + "index", [pytest.param(0, id="minimum"), pytest.param(1, id="inside")] +) +def test_markdown_image_index_accepts_nonnegative_boundaries(index: int) -> None: + source = _make_format_file(".md", "text/markdown") + options = {"markdown": {"image_sources": {"logo": {"image_id_index": index}}}} + payload = ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "structured_text_options": options} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": options, + } + + +def test_markdown_image_index_rejects_negative_boundary() -> None: + source = _make_format_file(".md", "text/markdown") + with pytest.raises( + ValidationError, match=r"(?s)image_id_index.*greater than or equal to 0" + ): + ConvertMarkdownToPdfPayload.model_validate( + { + "files": source, + "structured_text_options": { + "markdown": {"image_sources": {"logo": {"image_id_index": -1}}} + }, + } + ) + + +@pytest.mark.parametrize( + "count", [pytest.param(1, id="minimum"), pytest.param(2, id="inside")] +) +def test_markdown_image_ids_accept_nonempty_boundaries(count: int) -> None: + source = _make_format_file(".md", "text/markdown") + image = make_image_file(str(PdfRestFileID.generate(2)), "image/png", "logo.png") + payload = ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "image_ids": [image] * count} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "image_ids": [str(image.id)] * count, + } + + +def test_markdown_image_ids_reject_empty_list() -> None: + source = _make_format_file(".md", "text/markdown") + with pytest.raises(ValidationError, match=r"(?s)image_ids.*at least 1 item"): + ConvertMarkdownToPdfPayload.model_validate({"files": source, "image_ids": []}) diff --git a/uv.lock b/uv.lock index a48fd046..4461ce38 100644 --- a/uv.lock +++ b/uv.lock @@ -964,7 +964,7 @@ wheels = [ [[package]] name = "pdfrest" -version = "1.1.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "exceptiongroup" },