diff --git a/.agents/skills/pdfrest-client-api/SKILL.md b/.agents/skills/pdfrest-client-api/SKILL.md index df05bb34..ef9537bf 100644 --- a/.agents/skills/pdfrest-client-api/SKILL.md +++ b/.agents/skills/pdfrest-client-api/SKILL.md @@ -47,6 +47,39 @@ before making substantive edits. Name a helper for the user outcome, not the path or OpenAPI operation ID. +### Decide helper granularity with an applicability matrix + +Before choosing one helper or several, derive a matrix from the OpenAPI +contract. Use one row per user-recognizable source type or workflow and record: + +- accepted MIME types and filename extensions; +- required inputs and resource cardinality; +- optional fields, classified as universal, subset-only, or variant-exclusive; +- output/response shape and any materially different validation or lifecycle. + +Prefer focused helpers when the caller knows the source/workflow before the call +and a combined signature would expose keywords that are invalid for some rows, +depend on a mode or file type for their meaning, require extensive cross-field +runtime rejection, or prevent the type checker/editor from showing the valid +option set. Distinct file-family validation or a meaningful cluster of +row-specific options is strong evidence for a split. The fact that variants +share an HTTP path, OpenAPI operation, or nested wire object is not evidence +that they should share a public helper. + +Keep one helper when the rows share one coherent input contract and outcome and +nearly all options apply uniformly. A single helper can also be appropriate when +a natural discriminated `TypedDict`/model union expresses each variant without a +kitchen-sink keyword signature and static typing rejects invalid combinations. +Do not add a synthetic mode discriminator merely to avoid naming clear user +workflows. + +When splitting, keep universal keywords and request-customization arguments +consistent across helpers, reuse internal base/nested models for common wire +fields, and give each helper a narrow payload model for its applicable options +and file-family validation. Add tests proving every helper rejects the other +families before transport and never serializes an option that is inapplicable to +its row. + ## Versioning new APIs Adding a public API is a feature release and requires a minor-version bump. @@ -70,10 +103,10 @@ commits: - Name the material source/result or effect: `convert_html_to_pdf`, `add_text_to_pdf`, and `merge_pdfs`. Include both sides of a conversion. -- Split kitchen-sink routes into distinct helpers when source type, output, - validation, or user workflow differs. `/pdf` correctly maps to helpers such as - `convert_office_to_pdf`, `convert_html_to_pdf`, and `convert_url_to_pdf`, not - one mode-driven endpoint wrapper. +- Split kitchen-sink routes according to the applicability-matrix decision + above. `/pdf` correctly maps to helpers such as `convert_office_to_pdf`, + `convert_html_to_pdf`, and `convert_url_to_pdf`, not one mode-driven endpoint + wrapper. - Use a qualifier only when it changes the contract or workflow, such as `preview_redactions` then `apply_redactions`, or text versus image diff --git a/AGENTS.md b/AGENTS.md index 7bdf2d02..08e2daaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,23 @@ payload models (`model_validate`). Avoid duplicating payload validation in client methods or raising configuration errors for payload-shape issues that Pydantic validators can enforce. +- Decide public helper granularity from an applicability matrix, not from the + number of HTTP routes. List each user-recognizable source/workflow variant + against its accepted MIME types/extensions, required inputs, optional fields, + output shape, and validation rules; classify every option as universal, + subset-only, or variant-exclusive. +- Split one server operation into focused helpers when the source/workflow is + known before the call and a combined signature would expose options that are + invalid for some variants, require mode-dependent runtime checks, or weaken + editor/type-checker guidance. Distinct file-family validation or a meaningful + cluster of variant-only options is strong evidence for a split; a shared path + or wire object is not evidence for one public helper. +- Keep one helper when the variants share one coherent input contract and + outcome, or when a natural discriminated public input can make every valid + combination statically explicit without a kitchen-sink keyword signature. When + helpers are split, keep universal keywords consistent, share internal + base/nested payload models, and give each helper its own narrow payload model + that rejects other variants before transport execution. - Prefer Pydantic-backed JSON serialization for performance: use `model_dump_json()` for Pydantic models, and use `pydantic_core.to_json()` for non-model payloads instead of `json.dumps()` where practical. @@ -397,6 +414,11 @@ - Follow the `area: summary` convention seen in `pdfassistant-chatbot` (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`, + `models`, `tests`, `examples`, `docs`, or `pyproject`. Do not use generic + category or intent labels such as `guidance`, `changes`, `maintenance`, or + `misc`. - Keep commit messages imperative and focused; squash fixups before opening a PR. - Reference related issues or tickets in the PR description, and highlight 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..dc85828d 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -18,7 +18,7 @@ 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 +38,11 @@ PdfPageSize, PdfPresetColorProfile, PdfRestriction, + PdfStructuredTextDataPresentation, + PdfStructuredTextLineHandling, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageOrientation, + PdfStructuredTextTextAlignment, PdfXType, SummaryFormat, SummaryOutputFormat, @@ -275,6 +280,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 +922,427 @@ 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)] + + +class _StrictStructuredTextModel(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + +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: _NonEmptyStructuredString | None = 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: _NonEmptyStructuredString | None = None + heading_font: _NonEmptyStructuredString | None = None + code_font: _NonEmptyStructuredString | None = None + cjk_font: _NonEmptyStructuredString | None = None + fallback_fonts: Annotated[ + list[_NonEmptyStructuredString] | 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: _NonEmptyStructuredString | None = None + language: _NonEmptyStructuredString | 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..b820b758 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -46,6 +46,18 @@ PdfSignatureDisplay, PdfSignatureLocation, PdfSignaturePoint, + PdfStructuredTextCellPadding, + PdfStructuredTextCsvColumn, + PdfStructuredTextDataPresentation, + PdfStructuredTextImageSources, + PdfStructuredTextLineHandling, + PdfStructuredTextMargin, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageOrientation, + PdfStructuredTextPageSetup, + PdfStructuredTextStyle, + PdfStructuredTextTableStyle, + PdfStructuredTextTextAlignment, PdfTextColor, PdfXType, PngColorModel, @@ -104,6 +116,18 @@ "PdfSignatureDisplay", "PdfSignatureLocation", "PdfSignaturePoint", + "PdfStructuredTextCellPadding", + "PdfStructuredTextCsvColumn", + "PdfStructuredTextDataPresentation", + "PdfStructuredTextImageSources", + "PdfStructuredTextLineHandling", + "PdfStructuredTextMargin", + "PdfStructuredTextMissingImageAltText", + "PdfStructuredTextPageOrientation", + "PdfStructuredTextPageSetup", + "PdfStructuredTextStyle", + "PdfStructuredTextTableStyle", + "PdfStructuredTextTextAlignment", "PdfTextColor", "PdfXType", "PngColorModel", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 09c065cb..3afa68b4 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,18 @@ "PdfSignatureDisplay", "PdfSignatureLocation", "PdfSignaturePoint", + "PdfStructuredTextCellPadding", + "PdfStructuredTextCsvColumn", + "PdfStructuredTextDataPresentation", + "PdfStructuredTextImageSources", + "PdfStructuredTextLineHandling", + "PdfStructuredTextMargin", + "PdfStructuredTextMissingImageAltText", + "PdfStructuredTextPageOrientation", + "PdfStructuredTextPageSetup", + "PdfStructuredTextStyle", + "PdfStructuredTextTableStyle", + "PdfStructuredTextTextAlignment", "PdfTextColor", "PdfXType", "PngColorModel", @@ -143,6 +155,187 @@ 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. +""" + +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 non-empty page-size name understood by pdfRest, such as + ``Letter`` or ``A4``. + 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: str + 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. + + Attributes: + font: Optional non-empty body-text font family. + heading_font: Optional non-empty heading font family. + code_font: Optional non-empty code/preformatted-text font family. + cjk_font: Optional non-empty Chinese, Japanese, and Korean font family. + fallback_fonts: Optional non-empty ordered fallback-font family list. + 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: str + heading_font: str + code_font: str + cjk_font: str + fallback_fonts: Sequence[str] + 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 = 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..ec939f81 --- /dev/null +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse +from pdfrest.types import ( + PdfStructuredTextCsvColumn, + PdfStructuredTextDataPresentation, + PdfStructuredTextLineHandling, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageOrientation, + PdfStructuredTextTextAlignment, +) + +from ..resources import get_test_resource_path + + +@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"), + "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"), + } + 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("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.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("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"} + }, + ) 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.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..f2061517 --- /dev/null +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -0,0 +1,978 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +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 .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, +) + + +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("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" + + +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 +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), + } 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" },