From 05748118a683e69075ac6641a389f2f5749d67e6 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:36:09 -0500 Subject: [PATCH 01/10] models: Add structured document conversion contracts - Export public structured document types for page, style, table, and format-specific options. - Add format-specific payload validation and nested wire serialization. - Deduplicate uploaded Markdown images into ordered resource IDs. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 442 +++++++++++++++++++++++++++++++- src/pdfrest/types/__init__.py | 24 ++ src/pdfrest/types/public.py | 161 +++++++++++- 3 files changed, 625 insertions(+), 2 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 8a72654..dc85828 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 64cdb0e..b820b75 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 09c065c..c0521ff 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,153 @@ class PdfRedactionInstruction(TypedDict): PdfColor = PdfRGBColor | PdfCMYKColor PdfTextColor = PdfColor +PdfStructuredTextDataPresentation: TypeAlias = Literal["source", "hierarchy"] +"""JSON/XML presentation accepted by structured document conversion helpers.""" + +PdfStructuredTextPageOrientation: TypeAlias = Literal["auto", "portrait", "landscape"] +"""Page orientation accepted by structured document conversion helpers.""" + +PdfStructuredTextMissingImageAltText: TypeAlias = Literal["warn", "fail", "artifact"] +"""Policy for Markdown images that do not have alternate text.""" + +PdfStructuredTextLineHandling: TypeAlias = Literal["reflow", "preserve"] +"""Line-break handling accepted by ``convert_plain_text_to_pdf``.""" + +PdfStructuredTextTextAlignment: TypeAlias = Literal["left", "center", "right"] +"""CSV column text alignment accepted by ``convert_csv_to_pdf``.""" + + +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", From 1f858de23b7811e82ec9a04db411ef7e12aee30f Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:36:33 -0500 Subject: [PATCH 02/10] client: Add structured document conversion helpers - Add synchronous and asynchronous helpers for Markdown, plain text, JSON, XML, and CSV inputs. - Cover exact request serialization, validation boundaries, transport behavior, and request customization. Assisted-by: Codex --- src/pdfrest/client.py | 538 ++++++++++++ ...est_convert_structured_documents_to_pdf.py | 819 ++++++++++++++++++ 2 files changed, 1357 insertions(+) create mode 100644 tests/test_convert_structured_documents_to_pdf.py diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index dd1cc72..d7662c9 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/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py new file mode 100644 index 0000000..162ec2e --- /dev/null +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -0,0 +1,819 @@ +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( + {"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( + {"table_style": {"border_width": 12.1}}, + "less than or equal to 12", + id="border", + ), + pytest.param( + {"table_style": {"cell_padding": {"top": 73}}}, + "less than or equal to 72", + id="padding", + ), + pytest.param( + {"style": {"text_color_rgb": (0, 0, 256)}}, + "less than or equal to 255", + id="rgb", + ), + 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"), + {"columns": [{"index": -1}]}, + "greater than or equal to 0", + id="column-index", + ), + ], +) +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}) + + +def test_sync_client_rejects_invalid_markdown_before_transport() -> None: + source = _make_format_file(".txt", "text/plain") + + 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="Must be a Markdown file"), + ): + client.convert_markdown_to_pdf(source) + + +@pytest.mark.asyncio +async def test_async_client_rejects_invalid_csv_before_transport() -> None: + source = _make_format_file(".json", "application/json") + + 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="Must be a CSV file"): + await client.convert_csv_to_pdf(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" + + +def test_convert_markdown_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(".md", "text/markdown") + 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: + client.convert_markdown_to_pdf( + source, + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"debug": True}, + timeout=0.5, + ) + assert all(value == pytest.approx(0.5) for value in captured_timeout.values()) + + +@pytest.mark.asyncio +async def test_async_convert_csv_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(".csv", "text/csv") + 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 client.convert_csv_to_pdf( + source, + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"debug": True}, + timeout=0.6, + ) + assert all(value == pytest.approx(0.6) for value in captured_timeout.values()) From a34a8f8afbc042375e1ffad992e66f44ebb96abb Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:37:08 -0500 Subject: [PATCH 03/10] tests: Add live structured document conversion coverage - Exercise all five structured formats through synchronous and asynchronous clients. - Verify Markdown image resources, response metadata, and server-side invalid-option handling against deterministic fixtures. Assisted-by: Codex --- ...ive_convert_structured_documents_to_pdf.py | 290 ++++++++++++++++++ .../structured-document-with-image.md | 5 + tests/resources/structured-document.csv | 3 + tests/resources/structured-document.json | 4 + tests/resources/structured-document.md | 3 + tests/resources/structured-document.txt | 3 + tests/resources/structured-document.xml | 4 + 7 files changed, 312 insertions(+) create mode 100644 tests/live/test_live_convert_structured_documents_to_pdf.py create mode 100644 tests/resources/structured-document-with-image.md create mode 100644 tests/resources/structured-document.csv create mode 100644 tests/resources/structured-document.json create mode 100644 tests/resources/structured-document.md create mode 100644 tests/resources/structured-document.txt create mode 100644 tests/resources/structured-document.xml 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 0000000..339992c --- /dev/null +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -0,0 +1,290 @@ +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 + +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) + + +def test_live_convert_markdown_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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="fail", + output="live-markdown", + ), + ) + _assert_structured_pdf(response, source, "live-markdown") + assert response.input_ids == [source.id, image.id] + + +def test_live_convert_plain_text_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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="preserve", output="live-plain-text" + ), + ) + _assert_structured_pdf(response, source, "live-plain-text") + + +def test_live_convert_json_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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="hierarchy", output="live-json" + ), + ) + _assert_structured_pdf(response, source, "live-json") + + +def test_live_convert_xml_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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="source", output="live-xml" + ), + ) + _assert_structured_pdf(response, source, "live-xml") + + +def test_live_convert_csv_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["csv"] + columns = [ + PdfStructuredTextCsvColumn(index=0, text_align="left", width_weight=2), + PdfStructuredTextCsvColumn(index=1, text_align="right", 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="live-csv", + ), + ) + _assert_structured_pdf(response, source, "live-csv") + + +@pytest.mark.asyncio +async def test_live_async_convert_markdown_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["markdown"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_markdown_to_pdf( + source, enable_tagging=True, output="live-markdown-async" + ), + ) + _assert_structured_pdf(response, source, "live-markdown-async") + + +@pytest.mark.asyncio +async def test_live_async_convert_plain_text_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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="reflow", output="live-plain-text-async" + ), + ) + _assert_structured_pdf(response, source, "live-plain-text-async") + + +@pytest.mark.asyncio +async def test_live_async_convert_json_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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="source", output="live-json-async" + ), + ) + _assert_structured_pdf(response, source, "live-json-async") + + +@pytest.mark.asyncio +async def test_live_async_convert_xml_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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="hierarchy", output="live-xml-async" + ), + ) + _assert_structured_pdf(response, source, "live-xml-async") + + +@pytest.mark.asyncio +async def test_live_async_convert_csv_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> 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, delimiter=",", output="live-csv-async" + ), + ) + _assert_structured_pdf(response, source, "live-csv-async") + + +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 0000000..4b46f10 --- /dev/null +++ b/tests/resources/structured-document-with-image.md @@ -0,0 +1,5 @@ +# Quarterly service summary + +![Company logo](company-logo) + +Availability improved during the quarter while response times remained stable. diff --git a/tests/resources/structured-document.csv b/tests/resources/structured-document.csv new file mode 100644 index 0000000..cec13d9 --- /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 0000000..beb5185 --- /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 0000000..62e28e3 --- /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 0000000..967f712 --- /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 0000000..158cf84 --- /dev/null +++ b/tests/resources/structured-document.xml @@ -0,0 +1,4 @@ + + + 99.98 + From 80452c82b843f3cebf9664010edd031480eea593 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:38:36 -0500 Subject: [PATCH 04/10] examples: Demonstrate structured document conversions - Add a runnable upload-first example covering Markdown, plain text, JSON, XML, and CSV helpers. - Add deterministic source documents and register the example in the inventory. Assisted-by: Codex --- examples/README.md | 3 + ...ert_structured_documents_to_pdf_example.py | 144 ++++++++++++++++++ examples/resources/structured-document.csv | 3 + examples/resources/structured-document.json | 5 + examples/resources/structured-document.md | 10 ++ examples/resources/structured-document.txt | 4 + examples/resources/structured-document.xml | 6 + 7 files changed, 175 insertions(+) create mode 100644 examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py create mode 100644 examples/resources/structured-document.csv create mode 100644 examples/resources/structured-document.json create mode 100644 examples/resources/structured-document.md create mode 100644 examples/resources/structured-document.txt create mode 100644 examples/resources/structured-document.xml diff --git a/examples/README.md b/examples/README.md index b308991..4493fdd 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 0000000..5db3cbd --- /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 0000000..cec13d9 --- /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 0000000..cee3ecf --- /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 0000000..05d53db --- /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 0000000..e7f2ba6 --- /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 0000000..dcdb70d --- /dev/null +++ b/examples/resources/structured-document.xml @@ -0,0 +1,6 @@ + + + 99.98 + accessibility + document generation + From 0ef683c56ec3d674e5e5851c1e96e74818189bcf Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:39:00 -0500 Subject: [PATCH 05/10] docs: Document structured document conversion helpers - Add all five format-specific conversion helpers to the Into PDF API guide. - Link generated method signatures to their public structured option types. Assisted-by: Codex --- docs/api-guide.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/api-guide.md b/docs/api-guide.md index aacfafa..60e1d29 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], From 5a81c4a320c2b0f7f809eed83f4755033460de18 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:39:16 -0500 Subject: [PATCH 06/10] pyproject: Bump version to 1.2.0 - Release the structured document conversion helpers as a minor feature update. - Keep the project metadata and lockfile package version synchronized. Assisted-by: Codex --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2bdc5de..093ab5e 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/uv.lock b/uv.lock index a48fd04..4461ce3 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" }, From bb584d41c674f0bbc80a6c64c505ccd0c8dd1634 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 19:05:50 -0500 Subject: [PATCH 07/10] types: Document structured text literal values - Explain data presentation, page orientation, image alt-text policy, line handling, and CSV alignment values. - Keep value documentation on public aliases for generated API reference reuse. Assisted-by: Codex --- src/pdfrest/types/public.py | 44 ++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index c0521ff..3afa68b 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -156,19 +156,53 @@ class PdfRedactionInstruction(TypedDict): PdfTextColor = PdfColor PdfStructuredTextDataPresentation: TypeAlias = Literal["source", "hierarchy"] -"""JSON/XML presentation accepted by structured document conversion helpers.""" +"""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.""" +"""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.""" +"""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``.""" +"""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``.""" +"""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): From de4264eebce7d737f8db40cd6eb42ae6c560a2b1 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 29 Aug 2026 01:40:38 -0500 Subject: [PATCH 08/10] tests: Enumerate structured conversion live options Exercise every structured-document literal through both client transports against the live service, including shared page orientation. Assisted-by: Codex --- ...ive_convert_structured_documents_to_pdf.py | 169 ++++++++++++++---- 1 file changed, 134 insertions(+), 35 deletions(-) diff --git a/tests/live/test_live_convert_structured_documents_to_pdf.py b/tests/live/test_live_convert_structured_documents_to_pdf.py index 339992c..ec939f8 100644 --- a/tests/live/test_live_convert_structured_documents_to_pdf.py +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -6,7 +6,14 @@ from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse -from pdfrest.types import PdfStructuredTextCsvColumn +from pdfrest.types import ( + PdfStructuredTextCsvColumn, + PdfStructuredTextDataPresentation, + PdfStructuredTextLineHandling, + PdfStructuredTextMissingImageAltText, + PdfStructuredTextPageOrientation, + PdfStructuredTextTextAlignment, +) from ..resources import get_test_resource_path @@ -71,10 +78,12 @@ async def _run_async( return await invoke(client) -def test_live_convert_markdown_to_pdf_success( +@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"] @@ -85,71 +94,106 @@ def test_live_convert_markdown_to_pdf_success( source, image_sources={"company-logo": image}, image_alt_text={"company-logo": "Datalogics company logo"}, - missing_image_alt_text="fail", - output="live-markdown", + missing_image_alt_text=missing_image_alt_text, + output=f"live-markdown-{missing_image_alt_text}", ), ) - _assert_structured_pdf(response, source, "live-markdown") + _assert_structured_pdf(response, source, f"live-markdown-{missing_image_alt_text}") assert response.input_ids == [source.id, image.id] -def test_live_convert_plain_text_to_pdf_success( +@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="preserve", output="live-plain-text" + source, + line_handling=line_handling, + output=f"live-plain-text-{line_handling}", ), ) - _assert_structured_pdf(response, source, "live-plain-text") + _assert_structured_pdf(response, source, f"live-plain-text-{line_handling}") -def test_live_convert_json_to_pdf_success( +@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="hierarchy", output="live-json" + source, + data_presentation=data_presentation, + output=f"live-json-{data_presentation}", ), ) - _assert_structured_pdf(response, source, "live-json") + _assert_structured_pdf(response, source, f"live-json-{data_presentation}") -def test_live_convert_xml_to_pdf_success( +@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="source", output="live-xml" + source, + data_presentation=data_presentation, + output=f"live-xml-{data_presentation}", ), ) - _assert_structured_pdf(response, source, "live-xml") + _assert_structured_pdf(response, source, f"live-xml-{data_presentation}") -def test_live_convert_csv_to_pdf_success( +@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="left", width_weight=2), - PdfStructuredTextCsvColumn(index=1, text_align="right", width_weight=1), + PdfStructuredTextCsvColumn(index=0, text_align=text_align, width_weight=1) ] response = _run_sync( pdfrest_api_key, @@ -158,95 +202,150 @@ def test_live_convert_csv_to_pdf_success( source, first_row_is_header=True, columns=columns, - output="live-csv", + output=f"live-csv-{text_align}", ), ) - _assert_structured_pdf(response, source, "live-csv") + _assert_structured_pdf(response, source, f"live-csv-{text_align}") @pytest.mark.asyncio -async def test_live_async_convert_markdown_to_pdf_success( +@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"] + 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, enable_tagging=True, output="live-markdown-async" + 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, "live-markdown-async") + _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 -async def test_live_async_convert_plain_text_to_pdf_success( +@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, line_handling="reflow", output="live-plain-text-async" + source, + page_setup={"orientation": orientation}, + output=f"live-plain-text-async-orientation-{orientation}", ), ) - _assert_structured_pdf(response, source, "live-plain-text-async") + _assert_structured_pdf( + response, source, f"live-plain-text-async-orientation-{orientation}" + ) @pytest.mark.asyncio -async def test_live_async_convert_json_to_pdf_success( +@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="source", output="live-json-async" + source, + data_presentation=data_presentation, + output=f"live-json-async-{data_presentation}", ), ) - _assert_structured_pdf(response, source, "live-json-async") + _assert_structured_pdf(response, source, f"live-json-async-{data_presentation}") @pytest.mark.asyncio -async def test_live_async_convert_xml_to_pdf_success( +@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="hierarchy", output="live-xml-async" + source, + data_presentation=data_presentation, + output=f"live-xml-async-{data_presentation}", ), ) - _assert_structured_pdf(response, source, "live-xml-async") + _assert_structured_pdf(response, source, f"live-xml-async-{data_presentation}") @pytest.mark.asyncio -async def test_live_async_convert_csv_to_pdf_success( +@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, delimiter=",", output="live-csv-async" + 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, "live-csv-async") + _assert_structured_pdf(response, source, f"live-csv-async-{text_align}") def test_live_convert_json_to_pdf_rejects_invalid_option( From f0e645e801ad19557458565b47a9b5dc7cbc4706 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 29 Aug 2026 01:42:19 -0500 Subject: [PATCH 09/10] tests: Cover structured conversion client validation Verify every helper rejects every other structured source before transport, and covers sync and async request customization timeouts. Assisted-by: Codex --- ...est_convert_structured_documents_to_pdf.py | 142 ++++++++++++++++-- 1 file changed, 126 insertions(+), 16 deletions(-) diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index 162ec2e..4864020 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -536,8 +536,45 @@ def test_payloads_reject_invalid_literals_and_boundaries( payload_model.model_validate({"files": source, **options}) -def test_sync_client_rejects_invalid_markdown_before_transport() -> None: - source = _make_format_file(".txt", "text/plain") +@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") @@ -546,14 +583,51 @@ def fail_transport(_: httpx.Request) -> httpx.Response: PdfRestClient( api_key=VALID_API_KEY, transport=httpx.MockTransport(fail_transport) ) as client, - pytest.raises(ValidationError, match="Must be a Markdown file"), + pytest.raises(ValidationError, match=expected_message), ): - client.convert_markdown_to_pdf(source) + getattr(client, method_name)(source) @pytest.mark.asyncio -async def test_async_client_rejects_invalid_csv_before_transport() -> None: - source = _make_format_file(".json", "application/json") +@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") @@ -561,8 +635,8 @@ def fail_transport(_: httpx.Request) -> httpx.Response: async with AsyncPdfRestClient( api_key=ASYNC_API_KEY, transport=httpx.MockTransport(fail_transport) ) as client: - with pytest.raises(ValidationError, match="Must be a CSV file"): - await client.convert_csv_to_pdf(source) + with pytest.raises(ValidationError, match=expected_message): + await getattr(client, method_name)(source) def test_convert_markdown_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: @@ -748,11 +822,24 @@ async def test_async_convert_csv_to_pdf_success( assert response.output_file.type == "application/pdf" -def test_convert_markdown_to_pdf_request_customization( +@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(".md", "text/markdown") + source = _make_format_file(extension, mime_type) output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float] = {} @@ -773,22 +860,40 @@ def handler(request: httpx.Request) -> httpx.Response: with PdfRestClient( api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) ) as client: - client.convert_markdown_to_pdf( + getattr(client, method_name)( source, extra_query={"trace": "sync"}, extra_headers={"X-Debug": "sync"}, extra_body={"debug": True}, timeout=0.5, ) - assert all(value == pytest.approx(0.5) for value in captured_timeout.values()) + 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 -async def test_async_convert_csv_to_pdf_request_customization( +@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(".csv", "text/csv") + source = _make_format_file(extension, mime_type) output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float] = {} @@ -809,11 +914,16 @@ def handler(request: httpx.Request) -> httpx.Response: async with AsyncPdfRestClient( api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) ) as client: - await client.convert_csv_to_pdf( + await getattr(client, method_name)( source, extra_query={"trace": "async"}, extra_headers={"X-Debug": "async"}, extra_body={"debug": True}, timeout=0.6, ) - assert all(value == pytest.approx(0.6) for value in captured_timeout.values()) + assert captured_timeout == { + "connect": pytest.approx(0.6), + "read": pytest.approx(0.6), + "write": pytest.approx(0.6), + "pool": pytest.approx(0.6), + } From 8a5b0399a8af77f7ee8b95ef1527cc1a33d10298 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 29 Aug 2026 01:43:04 -0500 Subject: [PATCH 10/10] tests: Cover structured conversion lower bounds Reject below-minimum page, style, table, color, and CSV values so structured conversion constraints are tested on both boundaries. Assisted-by: Codex --- ...est_convert_structured_documents_to_pdf.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index 4864020..f206151 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -322,6 +322,16 @@ def test_format_payloads_reject_other_file_families_and_multiple_files( 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" ), @@ -330,21 +340,46 @@ def test_format_payloads_reject_other_file_families_and_multiple_files( "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" ), @@ -516,6 +551,13 @@ def test_markdown_payload_accepts_numeric_boundaries(options: dict[str, Any]) -> "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"), @@ -523,6 +565,13 @@ def test_markdown_payload_accepts_numeric_boundaries(options: dict[str, Any]) -> "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(