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
+
+
+
+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" },