From 78cff924c72b116bde99ca858e1c06dec5bd4136 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:36:09 -0500 Subject: [PATCH 01/18] 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 9bb02c6..80a6c5c 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: TypeAlias = Literal[ "P", "H", From 5ac43258c57ee3a9b089eca11bc35c2892579a17 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:36:33 -0500 Subject: [PATCH 02/18] 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 527fae6591e5973a64eb3d6ef1afc3dfa06a88e0 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:37:08 -0500 Subject: [PATCH 03/18] 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 3e18276e6a1c1c0a9f549632030af9905ab924f8 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:38:36 -0500 Subject: [PATCH 04/18] 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 a5df36dce3128788267633d79c0c9ecedb345ee0 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:39:00 -0500 Subject: [PATCH 05/18] 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 f0dfc091ec182ec3780ff4068fb650d03eea00ee Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 17:39:16 -0500 Subject: [PATCH 06/18] 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 eef7cc1b546542b92940fd4f8f3e67cec32445dd Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 28 Aug 2026 19:05:50 -0500 Subject: [PATCH 07/18] 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 80a6c5c..2cacc32 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 197845727cf473435460f4ef8e24e23475431da2 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 29 Aug 2026 01:40:38 -0500 Subject: [PATCH 08/18] 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 f95be315a9bea21dafae65478805a01ce582eed5 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 29 Aug 2026 01:42:19 -0500 Subject: [PATCH 09/18] 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 dc4e458abbec12975665bf4c11d75bd220df86d8 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 29 Aug 2026 01:43:04 -0500 Subject: [PATCH 10/18] 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( From 8dd50606da1bbc03706e4d3a17bdea799fbb9746 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 23 Sep 2026 15:24:11 -0500 Subject: [PATCH 11/18] types: Add structured-text page and font choices - Enumerate named page sizes; use width and height for custom pages. - Publish known font tokens alongside open installed font names. - Test payloads and sync/async clients, including live size behavior. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 15 +- src/pdfrest/types/__init__.py | 6 + src/pdfrest/types/public.py | 197 +++++++++++- ...ive_convert_structured_documents_to_pdf.py | 147 +++++++++ ...est_convert_structured_documents_to_pdf.py | 289 +++++++++++++++++- 5 files changed, 634 insertions(+), 20 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index dc85828..779737b 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -39,9 +39,11 @@ PdfPresetColorProfile, PdfRestriction, PdfStructuredTextDataPresentation, + PdfStructuredTextFontName, PdfStructuredTextLineHandling, PdfStructuredTextMissingImageAltText, PdfStructuredTextPageOrientation, + PdfStructuredTextPageSize, PdfStructuredTextTextAlignment, PdfXType, SummaryFormat, @@ -942,6 +944,7 @@ class ConvertUrlToPdfPayload(BaseModel): ] _PositiveStructuredNumber = Annotated[float, Field(gt=0)] _NonEmptyStructuredString = Annotated[str, Field(min_length=1)] +_StructuredTextFontName = Annotated[PdfStructuredTextFontName, Field(min_length=1)] class _StrictStructuredTextModel(BaseModel): @@ -956,7 +959,7 @@ class _StructuredTextMargin(_StrictStructuredTextModel): class _StructuredTextPageSetup(_StrictStructuredTextModel): - size: _NonEmptyStructuredString | None = None + size: PdfStructuredTextPageSize | None = None width: Annotated[float | None, Field(gt=0)] = None height: Annotated[float | None, Field(gt=0)] = None orientation: PdfStructuredTextPageOrientation | None = None @@ -994,12 +997,12 @@ class _StructuredTextTableStyle(_StrictStructuredTextModel): class _StructuredTextStyle(_StrictStructuredTextModel): - font: _NonEmptyStructuredString | None = None - heading_font: _NonEmptyStructuredString | None = None - code_font: _NonEmptyStructuredString | None = None - cjk_font: _NonEmptyStructuredString | None = None + font: _StructuredTextFontName | None = None + heading_font: _StructuredTextFontName | None = None + code_font: _StructuredTextFontName | None = None + cjk_font: _StructuredTextFontName | None = None fallback_fonts: Annotated[ - list[_NonEmptyStructuredString] | None, Field(min_length=1) + list[_StructuredTextFontName] | None, Field(min_length=1) ] = None text_size: Annotated[float | None, Field(ge=6, le=72)] = None text_color_rgb: _StructuredRgbColor | None = None diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index b820b75..6e73600 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -49,12 +49,15 @@ PdfStructuredTextCellPadding, PdfStructuredTextCsvColumn, PdfStructuredTextDataPresentation, + PdfStructuredTextFontName, PdfStructuredTextImageSources, + PdfStructuredTextKnownFont, PdfStructuredTextLineHandling, PdfStructuredTextMargin, PdfStructuredTextMissingImageAltText, PdfStructuredTextPageOrientation, PdfStructuredTextPageSetup, + PdfStructuredTextPageSize, PdfStructuredTextStyle, PdfStructuredTextTableStyle, PdfStructuredTextTextAlignment, @@ -119,12 +122,15 @@ "PdfStructuredTextCellPadding", "PdfStructuredTextCsvColumn", "PdfStructuredTextDataPresentation", + "PdfStructuredTextFontName", "PdfStructuredTextImageSources", + "PdfStructuredTextKnownFont", "PdfStructuredTextLineHandling", "PdfStructuredTextMargin", "PdfStructuredTextMissingImageAltText", "PdfStructuredTextPageOrientation", "PdfStructuredTextPageSetup", + "PdfStructuredTextPageSize", "PdfStructuredTextStyle", "PdfStructuredTextTableStyle", "PdfStructuredTextTextAlignment", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 2cacc32..9033087 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -61,12 +61,15 @@ "PdfStructuredTextCellPadding", "PdfStructuredTextCsvColumn", "PdfStructuredTextDataPresentation", + "PdfStructuredTextFontName", "PdfStructuredTextImageSources", + "PdfStructuredTextKnownFont", "PdfStructuredTextLineHandling", "PdfStructuredTextMargin", "PdfStructuredTextMissingImageAltText", "PdfStructuredTextPageOrientation", "PdfStructuredTextPageSetup", + "PdfStructuredTextPageSize", "PdfStructuredTextStyle", "PdfStructuredTextTableStyle", "PdfStructuredTextTextAlignment", @@ -174,6 +177,167 @@ class PdfRedactionInstruction(TypedDict): - `landscape`: Use landscape page orientation. """ +PdfStructuredTextPageSize: TypeAlias = Literal[ + "Letter", "Legal", "Ledger", "A3", "A4", "A5", "Tabloid" +] +"""Named page size for structured document conversion. + +For a custom size, use ``PdfStructuredTextPageSetup.width`` and ``height``. + +Accepted values: + +- `Letter`: US letter paper, 612 by 792 PDF points. +- `Legal`: US legal paper, 612 by 1008 PDF points. +- `Ledger`: Ledger paper, 1224 by 792 PDF points. +- `A3`: ISO A3 paper, 842 by 1191 PDF points. +- `A4`: ISO A4 paper, 595 by 842 PDF points. +- `A5`: ISO A5 paper, 420 by 595 PDF points. +- `Tabloid`: Tabloid paper, 792 by 1224 PDF points. +""" + +PdfStructuredTextKnownFont: TypeAlias = Literal[ + "andalemono", + "arial", + "arialitalic", + "arialbold", + "arialblack", + "calibri", + "calibriitalic", + "calibribold", + "calibribolditalic", + "cambriaitalic", + "cambriabold", + "cambriabolditalic", + "candara", + "candaraitalic", + "candarabold", + "candarabolditalic", + "cantarellregular", + "cantarelloblique", + "cantarellbold", + "cantarellboldoblique", + "comicsans", + "comicsansbold", + "constantia", + "consolas", + "consolasitalic", + "consolasbold", + "consolasbolditalic", + "constantiaitalic", + "constantiabold", + "constantiabolditalic", + "corbel", + "corbelitalic", + "corbelbold", + "corbelbolditalic", + "courier", + "courieritalic", + "courierbold", + "courierbolditalic", + "dejavu", + "dejavu sans mono oblique", + "dejavu sans mono bold", + "dejavu sans mono bold oblique", + "georgia", + "georgiaitalic", + "georgiabold", + "georgiabolditalic", + "impact", + "tahoma", + "timesnewroman", + "timesnewromanitalic", + "timesnewromanbold", + "timesnewromanbolditalic", + "trebuchet", + "trebuchetitalic", + "trebuchetbold", + "trebuchetbolditalic", + "verdana", + "verdanaitalic", + "verdanabold", + "verdanabolditalic", + "webdings", +] +"""Published pdfRest font names for structured document conversion. + +These are suggestions, not a closed service catalog. An installed font name +outside this list may also be used through ``PdfStructuredTextFontName``. +See the [pdfRest Font List](https://pdfrest.com/documentation/fontlist/). + +Accepted values: + +- `andalemono`: Andale Mono. +- `arial`: Arial. +- `arialitalic`: Arial Italic. +- `arialbold`: Arial Bold. +- `arialblack`: Arial Black. +- `calibri`: Calibri. +- `calibriitalic`: Calibri Italic. +- `calibribold`: Calibri Bold. +- `calibribolditalic`: Calibri Bold Italic. +- `cambriaitalic`: Cambria Italic. +- `cambriabold`: Cambria Bold. +- `cambriabolditalic`: Cambria Bold Italic. +- `candara`: Candara. +- `candaraitalic`: Candara Italic. +- `candarabold`: Candara Bold. +- `candarabolditalic`: Candara Bold Italic. +- `cantarellregular`: Cantarell. +- `cantarelloblique`: Cantarell Oblique. +- `cantarellbold`: Cantarell Bold. +- `cantarellboldoblique`: Cantarell Bold Oblique. +- `comicsans`: Comic Sans. +- `comicsansbold`: Comic Sans Bold. +- `constantia`: Constantia. +- `consolas`: Consolas. +- `consolasitalic`: Consolas Italic. +- `consolasbold`: Consolas Bold. +- `consolasbolditalic`: Consolas Bold Italic. +- `constantiaitalic`: Constantia Italic. +- `constantiabold`: Constantia Bold. +- `constantiabolditalic`: Constantia Bold Italic. +- `corbel`: Corbel. +- `corbelitalic`: Corbel Italic. +- `corbelbold`: Corbel Bold. +- `corbelbolditalic`: Corbel Bold Italic. +- `courier`: Courier New. +- `courieritalic`: Courier New Italic. +- `courierbold`: Courier New Bold. +- `courierbolditalic`: Courier New Bold Italic. +- `dejavu`: DejaVu Sans Mono. +- `dejavu sans mono oblique`: DejaVu Sans Mono Oblique. +- `dejavu sans mono bold`: DejaVu Sans Mono Bold. +- `dejavu sans mono bold oblique`: DejaVu Sans Mono Bold Oblique. +- `georgia`: Georgia. +- `georgiaitalic`: Georgia Italic. +- `georgiabold`: Georgia Bold. +- `georgiabolditalic`: Georgia Bold Italic. +- `impact`: Impact. +- `tahoma`: Tahoma. +- `timesnewroman`: Times New Roman. +- `timesnewromanitalic`: Times New Roman Italic. +- `timesnewromanbold`: Times New Roman Bold. +- `timesnewromanbolditalic`: Times New Roman Bold Italic. +- `trebuchet`: Trebuchet. +- `trebuchetitalic`: Trebuchet Italic. +- `trebuchetbold`: Trebuchet Bold. +- `trebuchetbolditalic`: Trebuchet Bold Italic. +- `verdana`: Verdana. +- `verdanaitalic`: Verdana Italic. +- `verdanabold`: Verdana Bold. +- `verdanabolditalic`: Verdana Bold Italic. +- `webdings`: Webdings. +""" + +PdfStructuredTextFontName: TypeAlias = PdfStructuredTextKnownFont | str +"""A published pdfRest font token or another font name installed on the service. + +Accepted by the ``font``, ``heading_font``, ``code_font``, ``cjk_font``, and +``fallback_fonts`` fields of ``PdfStructuredTextStyle``. The published tokens +provide editor suggestions; the ``str`` branch keeps deployment-specific fonts +available. +""" + PdfStructuredTextMissingImageAltText: TypeAlias = Literal["warn", "fail", "artifact"] """Policy for Markdown images that do not have alternate text. @@ -225,8 +389,8 @@ 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``. + size: Optional named page size. For custom dimensions, supply ``width`` + and ``height`` instead. width: Optional custom page width in PDF points. Must be greater than 0 and supplied together with ``height``. height: Optional custom page height in PDF points. Must be greater than @@ -236,7 +400,7 @@ class PdfStructuredTextPageSetup(TypedDict, total=False): margin: Optional per-side margins in PDF points. """ - size: str + size: PdfStructuredTextPageSize width: float height: float orientation: PdfStructuredTextPageOrientation @@ -297,23 +461,30 @@ class PdfStructuredTextTableStyle(TypedDict, total=False): class PdfStructuredTextStyle(TypedDict, total=False): """Typography shared by all structured document conversion helpers. + The ``PdfStructuredTextFontName`` type suggests the published + ``PdfStructuredTextKnownFont`` values while allowing other names installed + on the service. See the + [pdfRest Font List](https://pdfrest.com/documentation/fontlist/). + Attributes: - font: Optional non-empty body-text font 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. + font: Optional non-empty body-text font name installed on the service. + heading_font: Optional non-empty installed heading font name. + code_font: Optional non-empty installed code/preformatted-text font name. + cjk_font: Optional non-empty installed Chinese, Japanese, or Korean font + name. This is a fallback, not a closed catalog. + fallback_fonts: Optional non-empty ordered list of installed fallback + font names. Availability depends on the service deployment. text_size: Optional body-text size in PDF points, from 6 through 72. text_color_rgb: Optional RGB body-text color with channels from 0 through 255. heading_scale: Optional heading scale greater than 0 and at most 4. """ - font: str - heading_font: str - code_font: str - cjk_font: str - fallback_fonts: Sequence[str] + font: PdfStructuredTextFontName + heading_font: PdfStructuredTextFontName + code_font: PdfStructuredTextFontName + cjk_font: PdfStructuredTextFontName + fallback_fonts: Sequence[PdfStructuredTextFontName] text_size: float text_color_rgb: PdfRGBColor heading_scale: float 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 ec939f8..af2a225 100644 --- a/tests/live/test_live_convert_structured_documents_to_pdf.py +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -9,14 +9,26 @@ from pdfrest.types import ( PdfStructuredTextCsvColumn, PdfStructuredTextDataPresentation, + PdfStructuredTextFontName, PdfStructuredTextLineHandling, PdfStructuredTextMissingImageAltText, PdfStructuredTextPageOrientation, + PdfStructuredTextPageSize, PdfStructuredTextTextAlignment, ) from ..resources import get_test_resource_path +NAMED_PAGE_SIZES = [ + pytest.param("Letter", id="letter"), + pytest.param("Legal", id="legal"), + pytest.param("Ledger", id="ledger"), + pytest.param("A3", id="a3"), + pytest.param("A4", id="a4"), + pytest.param("A5", id="a5"), + pytest.param("Tabloid", id="tabloid"), +] + @pytest.fixture(scope="module") def uploaded_structured_documents( @@ -144,6 +156,73 @@ def test_live_convert_plain_text_to_pdf_page_orientation( ) +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +def test_live_convert_plain_text_to_pdf_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + size: PdfStructuredTextPageSize, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + page_setup={"size": size}, + output=f"live-plain-text-page-size-{size.lower()}", + ), + ) + _assert_structured_pdf( + response, source, f"live-plain-text-page-size-{size.lower()}" + ) + + +def test_live_convert_plain_text_to_pdf_rejects_invalid_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["plain_text"] + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)page.?setup|size|invalid"), + ): + client.convert_plain_text_to_pdf( + source, + extra_body={ + "structured_text_options": {"page_setup": {"size": "Executive"}} + }, + ) + + +@pytest.mark.parametrize( + "font", + [ + pytest.param("arial", id="published-token"), + pytest.param("Noto Sans", id="installed-unlisted-name"), + ], +) +def test_live_convert_plain_text_to_pdf_font_name( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + font: PdfStructuredTextFontName, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, style={"font": font}, output="live-plain-text-font" + ), + ) + _assert_structured_pdf(response, source, "live-plain-text-font") + + @pytest.mark.parametrize("data_presentation", ["source", "hierarchy"]) def test_live_convert_json_to_pdf_data_presentation( pdfrest_api_key: str, @@ -280,6 +359,74 @@ async def test_live_async_convert_plain_text_to_pdf_page_orientation( ) +@pytest.mark.asyncio +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +async def test_live_async_convert_plain_text_to_pdf_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + size: PdfStructuredTextPageSize, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, + page_setup={"size": size}, + output=f"live-plain-text-async-page-size-{size.lower()}", + ), + ) + _assert_structured_pdf( + response, source, f"live-plain-text-async-page-size-{size.lower()}" + ) + + +@pytest.mark.asyncio +async def test_live_async_convert_plain_text_to_pdf_rejects_invalid_page_size( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], +) -> None: + source = uploaded_structured_documents["plain_text"] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)page.?setup|size|invalid"): + await client.convert_plain_text_to_pdf( + source, + extra_body={ + "structured_text_options": {"page_setup": {"size": "Executive"}} + }, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "font", + [ + pytest.param("arial", id="published-token"), + pytest.param("Noto Sans", id="installed-unlisted-name"), + ], +) +async def test_live_async_convert_plain_text_to_pdf_font_name( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + font: PdfStructuredTextFontName, +) -> None: + source = uploaded_structured_documents["plain_text"] + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_plain_text_to_pdf( + source, style={"font": font}, output="live-plain-text-async-font" + ), + ) + _assert_structured_pdf(response, source, "live-plain-text-async-font") + + @pytest.mark.asyncio @pytest.mark.parametrize("data_presentation", ["source", "hierarchy"]) async def test_live_async_convert_json_to_pdf_data_presentation( diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index f206151..8b363dd 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -2,7 +2,7 @@ import json from collections.abc import Callable -from typing import Any +from typing import Any, cast, get_args import httpx import pytest @@ -17,6 +17,13 @@ ConvertPlainTextToPdfPayload, ConvertXmlToPdfPayload, ) +from pdfrest.types import ( + PdfStructuredTextFontName, + PdfStructuredTextKnownFont, + PdfStructuredTextPageSetup, + PdfStructuredTextPageSize, + PdfStructuredTextStyle, +) from .convert_to_pdf_test_helpers import make_source_file from .graphics_test_helpers import ( @@ -26,6 +33,20 @@ make_image_file, ) +NAMED_PAGE_SIZES = [ + pytest.param("Letter", id="letter"), + pytest.param("Legal", id="legal"), + pytest.param("Ledger", id="ledger"), + pytest.param("A3", id="a3"), + pytest.param("A4", id="a4"), + pytest.param("A5", id="a5"), + pytest.param("Tabloid", id="tabloid"), +] +KNOWN_FONTS = [ + pytest.param(value, id=value.replace(" ", "-")) + for value in get_args(PdfStructuredTextKnownFont) +] + def _dump_payload(model: BaseModel) -> dict[str, Any]: return model.model_dump( @@ -424,6 +445,94 @@ def test_page_setup_accepts_every_orientation(orientation: str) -> None: ) +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +def test_page_setup_accepts_every_named_size(size: PdfStructuredTextPageSize) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "page_setup": {"size": size}} + ) + ) + assert payload["structured_text_options"]["page_setup"] == {"size": size} + + +def test_page_setup_rejects_unknown_named_size() -> None: + source = _make_format_file(".txt", "text/plain") + with pytest.raises(ValidationError, match=r"page_setup\.size"): + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "page_setup": {"size": "Executive"}} + ) + + +def test_page_setup_accepts_custom_dimensions_instead_of_size_string() -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "page_setup": {"width": 500, "height": 700}} + ) + ) + assert payload["structured_text_options"]["page_setup"] == { + "width": 500.0, + "height": 700.0, + } + + +@pytest.mark.parametrize("font", KNOWN_FONTS) +def test_style_accepts_every_published_font_name( + font: PdfStructuredTextKnownFont, +) -> None: + source = _make_format_file(".txt", "text/plain") + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + {"files": source, "style": {"font": font}} + ) + ) + assert payload["structured_text_options"]["style"] == {"font": font} + + +def test_style_accepts_open_font_names_in_all_font_fields() -> None: + source = _make_format_file(".txt", "text/plain") + other_font = "Noto Sans CJK JP" + payload = _dump_payload( + ConvertPlainTextToPdfPayload.model_validate( + { + "files": source, + "style": { + "font": other_font, + "heading_font": other_font, + "code_font": other_font, + "cjk_font": other_font, + "fallback_fonts": ["arial", other_font], + }, + } + ) + ) + assert payload["structured_text_options"]["style"] == { + "font": other_font, + "heading_font": other_font, + "code_font": other_font, + "cjk_font": other_font, + "fallback_fonts": ["arial", other_font], + } + + +@pytest.mark.parametrize( + "style", + [ + pytest.param({"font": ""}, id="body"), + pytest.param({"font": " "}, id="body-whitespace"), + pytest.param({"heading_font": ""}, id="heading"), + pytest.param({"code_font": ""}, id="code"), + pytest.param({"cjk_font": ""}, id="cjk"), + pytest.param({"fallback_fonts": [""]}, id="fallback"), + ], +) +def test_style_rejects_empty_font_names(style: dict[str, Any]) -> None: + source = _make_format_file(".txt", "text/plain") + with pytest.raises(ValidationError, match="at least 1 item"): + ConvertPlainTextToPdfPayload.model_validate({"files": source, "style": style}) + + @pytest.mark.parametrize("presentation", ["source", "hierarchy"]) @pytest.mark.parametrize( ("payload_model", "extension", "mime_type"), @@ -726,6 +835,94 @@ def test_convert_plain_text_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> N assert response.output_file.type == "application/pdf" +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +def test_convert_plain_text_to_pdf_accepts_every_named_size( + monkeypatch: pytest.MonkeyPatch, size: PdfStructuredTextPageSize +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"page_setup": {"size": size}}, + } + response = _sync_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf( + source, page_setup={"size": size} + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.parametrize( + "font", [*KNOWN_FONTS, pytest.param("Noto Sans CJK JP", id="installed-name")] +) +def test_convert_plain_text_to_pdf_accepts_known_or_open_font_name( + monkeypatch: pytest.MonkeyPatch, font: PdfStructuredTextFontName +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": {"font": font}}, + } + response = _sync_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style={"font": font}), + ) + assert response.output_file.type == "application/pdf" + + +def test_convert_plain_text_to_pdf_serializes_all_font_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".txt", "text/plain") + style = PdfStructuredTextStyle( + font="arial", + heading_font="Arial Bold", + code_font="courier", + cjk_font="Noto Sans CJK JP", + fallback_fonts=["arial", "Noto Sans"], + ) + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": style}, + } + response = _sync_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style=style), + ) + assert response.output_file.type == "application/pdf" + + +def test_convert_plain_text_to_pdf_rejects_unknown_size_before_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(".txt", "text/plain") + + def fail_transport(request: httpx.Request) -> httpx.Response: + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(fail_transport) + ) as client, + pytest.raises(ValidationError, match=r"page_setup\.size"), + ): + client.convert_plain_text_to_pdf( + source, + page_setup=cast( + PdfStructuredTextPageSetup, cast(object, {"size": "Executive"}) + ), + ) + + def test_convert_json_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: source = _make_format_file(".json", "application/json") payload = _dump_payload( @@ -820,6 +1017,96 @@ async def test_async_convert_plain_text_to_pdf_success( assert response.output_file.type == "application/pdf" +@pytest.mark.asyncio +@pytest.mark.parametrize("size", NAMED_PAGE_SIZES) +async def test_async_convert_plain_text_to_pdf_accepts_every_named_size( + monkeypatch: pytest.MonkeyPatch, size: PdfStructuredTextPageSize +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"page_setup": {"size": size}}, + } + response = await _async_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf( + source, page_setup={"size": size} + ), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "font", [*KNOWN_FONTS, pytest.param("Noto Sans CJK JP", id="installed-name")] +) +async def test_async_convert_plain_text_to_pdf_accepts_known_or_open_font_name( + monkeypatch: pytest.MonkeyPatch, font: PdfStructuredTextFontName +) -> None: + source = _make_format_file(".txt", "text/plain") + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": {"font": font}}, + } + response = await _async_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style={"font": font}), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_plain_text_to_pdf_serializes_all_font_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = _make_format_file(".txt", "text/plain") + style = PdfStructuredTextStyle( + font="arial", + heading_font="Arial Bold", + code_font="courier", + cjk_font="Noto Sans CJK JP", + fallback_fonts=["arial", "Noto Sans"], + ) + expected_payload = { + "id": str(source.id), + "structured_text_options": {"style": style}, + } + response = await _async_conversion( + monkeypatch, + source, + expected_payload, + lambda client: client.convert_plain_text_to_pdf(source, style=style), + ) + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +async def test_async_convert_plain_text_to_pdf_rejects_unknown_size_before_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + source = _make_format_file(".txt", "text/plain") + + def fail_transport(request: httpx.Request) -> httpx.Response: + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(fail_transport) + ) as client: + with pytest.raises(ValidationError, match=r"page_setup\.size"): + await client.convert_plain_text_to_pdf( + source, + page_setup=cast( + PdfStructuredTextPageSetup, cast(object, {"size": "Executive"}) + ), + ) + + @pytest.mark.asyncio async def test_async_convert_json_to_pdf_success( monkeypatch: pytest.MonkeyPatch, From 5fd61fce27c3c7e948971fb385f1acece7729aa1 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 8 Oct 2026 15:34:52 -0500 Subject: [PATCH 12/18] AGENTS: Clarify package-owned private imports Allow imports of private modules and symbols within the package while requiring supported public APIs for external dependencies, including other company-owned packages. Keep internal contracts out of public exports and permit focused internal tests when needed. Assisted-by: Codex --- AGENTS.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 06a3620..cc66308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,10 +220,14 @@ customization (sync + async), validation failures, and multi-file guards. Add a shared validation suite when multiple endpoints rely on the same input rules (e.g., `tests/test_graphic_payload_validation.py`). -- Do not import from private modules (names beginning with an underscore) in - production code. In tests, prefer public modules first; allow private-model - imports only when necessary to validate request serialization or mock - server-facing payload contracts that are not exposed publicly. +- Production code may import private modules and symbols within its own package, + including `pdfrest.client` importing `pdfrest.models._internal`. Do not import + private modules or symbols from external packages; use their supported public + APIs. Another company-owned package is still an external dependency. Keep + package-owned internals outside public exports unless intentionally promoted + to a supported API. In tests, prefer public APIs; access package-owned + internals when needed to verify implementation behavior or request + serialization. ## Testing Guidelines From 0706b7fbd0003e8ebdd32a3d2c78763bb1ceb361 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 8 Oct 2026 15:35:23 -0500 Subject: [PATCH 13/18] models: Preserve whitespace in structured conversion inputs Restrict whitespace trimming to titles, languages, and font names. Preserve CSV delimiters and image alternative text verbatim, and reject invalid delimiter lengths before executing either client transport. Add payload and sync/async request regressions. Retain live space/tab cases with an explicit skip until the CLU delimiter fix is deployed. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 22 +- ...ive_convert_structured_documents_to_pdf.py | 75 ++++++ ...est_convert_structured_documents_to_pdf.py | 222 ++++++++++++++++++ 3 files changed, 315 insertions(+), 4 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 779737b..cd13f58 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -15,6 +15,7 @@ Field, HttpUrl, PlainSerializer, + StringConstraints, model_serializer, model_validator, ) @@ -944,11 +945,24 @@ class ConvertUrlToPdfPayload(BaseModel): ] _PositiveStructuredNumber = Annotated[float, Field(gt=0)] _NonEmptyStructuredString = Annotated[str, Field(min_length=1)] -_StructuredTextFontName = Annotated[PdfStructuredTextFontName, Field(min_length=1)] +_TrimmedStructuredString = Annotated[ + str, StringConstraints(strip_whitespace=True, min_length=1) +] + + +def _strip_structured_string(value: Any) -> Any: + return value.strip() if isinstance(value, str) else value + + +_StructuredTextFontName = Annotated[ + PdfStructuredTextFontName, + BeforeValidator(_strip_structured_string), + Field(min_length=1), +] class _StrictStructuredTextModel(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + model_config = ConfigDict(extra="forbid") class _StructuredTextMargin(_StrictStructuredTextModel): @@ -1042,8 +1056,8 @@ class _StructuredTextCsvOptions(_StrictStructuredTextModel): class _StructuredTextOptionsBase(_StrictStructuredTextModel): - title: _NonEmptyStructuredString | None = None - language: _NonEmptyStructuredString | None = None + title: _TrimmedStructuredString | None = None + language: _TrimmedStructuredString | None = None enable_tagging: bool | None = None page_setup: _StructuredTextPageSetup | None = None 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 af2a225..ec706be 100644 --- a/tests/live/test_live_convert_structured_documents_to_pdf.py +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from pathlib import Path import pytest @@ -29,6 +30,11 @@ pytest.param("Tabloid", id="tabloid"), ] +WHITESPACE_DELIMITER_SKIP_REASON = ( + "Blocked by clu-structured-text-to-pdf CsvAdapter.ResolveDelimiter(): " + "space/tab delimiters fall back to comma. Enable after the CLU fix is deployed." +) + @pytest.fixture(scope="module") def uploaded_structured_documents( @@ -287,6 +293,75 @@ def test_live_convert_csv_to_pdf_text_align( _assert_structured_pdf(response, source, f"live-csv-{text_align}") +@pytest.fixture +def whitespace_csv_document( + tmp_path: Path, + delimiter: str, + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + """Upload input whose two columns use the delimiter under test.""" + path = tmp_path / "whitespace-delimited.csv" + path.write_text(f"Name{delimiter}Value\nAlpha{delimiter}1\n", encoding="utf-8") + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + return client.files.create_from_paths(path)[0] + + +@pytest.mark.skip(reason=WHITESPACE_DELIMITER_SKIP_REASON) +@pytest.mark.parametrize( + "delimiter", [pytest.param(" ", id="space"), pytest.param("\t", id="tab")] +) +def test_live_convert_csv_to_pdf_whitespace_delimiter( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + whitespace_csv_document: PdfRestFile, + delimiter: str, +) -> None: + """Selecting the second column requires the delimiter to split the input.""" + source = whitespace_csv_document + response = _run_sync( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_csv_to_pdf( + source, + delimiter=delimiter, + first_row_is_header=True, + columns=[PdfStructuredTextCsvColumn(index=1, text_align="right")], + output="live-csv-whitespace", + ), + ) + _assert_structured_pdf(response, source, "live-csv-whitespace") + + +@pytest.mark.asyncio +@pytest.mark.skip(reason=WHITESPACE_DELIMITER_SKIP_REASON) +@pytest.mark.parametrize( + "delimiter", [pytest.param(" ", id="space"), pytest.param("\t", id="tab")] +) +async def test_live_async_convert_csv_to_pdf_whitespace_delimiter( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + whitespace_csv_document: PdfRestFile, + delimiter: str, +) -> None: + """Selecting the second column requires the delimiter to split the input.""" + source = whitespace_csv_document + response = await _run_async( + pdfrest_api_key, + pdfrest_live_base_url, + lambda client: client.convert_csv_to_pdf( + source, + delimiter=delimiter, + first_row_is_header=True, + columns=[PdfStructuredTextCsvColumn(index=1, text_align="right")], + output="live-csv-async-whitespace", + ), + ) + _assert_structured_pdf(response, source, "live-csv-async-whitespace") + + @pytest.mark.asyncio @pytest.mark.parametrize("missing_image_alt_text", ["warn", "fail", "artifact"]) async def test_live_async_convert_markdown_to_pdf_missing_image_alt_text( diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index 8b363dd..7641762 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -47,6 +47,18 @@ for value in get_args(PdfStructuredTextKnownFont) ] +CSV_DELIMITERS = [ + pytest.param(" ", id="space"), + pytest.param("\t", id="tab"), + pytest.param(",", id="comma"), + pytest.param(";", id="semicolon"), +] +ALT_TEXT_VALUES = [ + pytest.param(" ", id="space"), + pytest.param("\t", id="tab"), + pytest.param(" Company logo ", id="padded-text"), +] + def _dump_payload(model: BaseModel) -> dict[str, Any]: return model.model_dump( @@ -1263,3 +1275,213 @@ def handler(request: httpx.Request) -> httpx.Response: "write": pytest.approx(0.6), "pool": pytest.approx(0.6), } + + +@pytest.mark.parametrize("delimiter", CSV_DELIMITERS) +def test_csv_payload_preserves_delimiter(delimiter: str) -> None: + source = _make_format_file(".csv", "text/csv") + payload = ConvertCsvToPdfPayload.model_validate( + {"files": source, "delimiter": delimiter} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": {"csv": {"delimiter": delimiter}}, + } + + +@pytest.mark.parametrize("delimiter", CSV_DELIMITERS) +def test_convert_csv_to_pdf_preserves_delimiter( + monkeypatch: pytest.MonkeyPatch, delimiter: str +) -> None: + source = _make_format_file(".csv", "text/csv") + _sync_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": {"csv": {"delimiter": delimiter}}, + }, + lambda client: client.convert_csv_to_pdf(source, delimiter=delimiter), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delimiter", CSV_DELIMITERS) +async def test_async_convert_csv_to_pdf_preserves_delimiter( + monkeypatch: pytest.MonkeyPatch, delimiter: str +) -> None: + source = _make_format_file(".csv", "text/csv") + await _async_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": {"csv": {"delimiter": delimiter}}, + }, + lambda client: client.convert_csv_to_pdf(source, delimiter=delimiter), + ) + + +@pytest.mark.parametrize("alt_text", ALT_TEXT_VALUES) +def test_markdown_payload_preserves_alt_text(alt_text: str) -> None: + source = _make_format_file(".md", "text/markdown") + payload = ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "image_alt_text": {"logo": alt_text}} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": {"markdown": {"image_alt_text": {"logo": alt_text}}}, + } + + +@pytest.mark.parametrize("alt_text", ALT_TEXT_VALUES) +def test_convert_markdown_to_pdf_preserves_alt_text( + monkeypatch: pytest.MonkeyPatch, alt_text: str +) -> None: + source = _make_format_file(".md", "text/markdown") + _sync_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": { + "markdown": {"image_alt_text": {"logo": alt_text}} + }, + }, + lambda client: client.convert_markdown_to_pdf( + source, image_alt_text={"logo": alt_text} + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("alt_text", ALT_TEXT_VALUES) +async def test_async_convert_markdown_to_pdf_preserves_alt_text( + monkeypatch: pytest.MonkeyPatch, alt_text: str +) -> None: + source = _make_format_file(".md", "text/markdown") + await _async_conversion( + monkeypatch, + source, + { + "id": str(source.id), + "structured_text_options": { + "markdown": {"image_alt_text": {"logo": alt_text}} + }, + }, + lambda client: client.convert_markdown_to_pdf( + source, image_alt_text={"logo": alt_text} + ), + ) + + +TRIMMED_TEXT_OPTIONS = [ + pytest.param({"title": " Report "}, {"title": "Report"}, id="title"), + pytest.param({"language": " en-US "}, {"language": "en-US"}, id="language"), + *[ + pytest.param( + {"style": {field: " Noto Sans "}}, + {"style": {field: "Noto Sans"}}, + id=field, + ) + for field in ("font", "heading_font", "code_font", "cjk_font") + ], + pytest.param( + {"style": {"fallback_fonts": [" arial ", " Noto Sans "]}}, + {"style": {"fallback_fonts": ["arial", "Noto Sans"]}}, + id="fallback-fonts", + ), +] + + +@pytest.mark.parametrize(("options", "expected_options"), TRIMMED_TEXT_OPTIONS) +def test_payload_trims_api_normalized_text( + options: dict[str, Any], expected_options: dict[str, Any] +) -> None: + source = _make_format_file(".txt", "text/plain") + payload = ConvertPlainTextToPdfPayload.model_validate({"files": source, **options}) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": expected_options, + } + + +@pytest.mark.parametrize(("options", "expected_options"), TRIMMED_TEXT_OPTIONS) +def test_convert_plain_text_to_pdf_trims_api_normalized_text( + monkeypatch: pytest.MonkeyPatch, + options: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + source = _make_format_file(".txt", "text/plain") + _sync_conversion( + monkeypatch, + source, + {"id": str(source.id), "structured_text_options": expected_options}, + lambda client: client.convert_plain_text_to_pdf(source, **options), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("options", "expected_options"), TRIMMED_TEXT_OPTIONS) +async def test_async_convert_plain_text_to_pdf_trims_api_normalized_text( + monkeypatch: pytest.MonkeyPatch, + options: dict[str, Any], + expected_options: dict[str, Any], +) -> None: + source = _make_format_file(".txt", "text/plain") + await _async_conversion( + monkeypatch, + source, + {"id": str(source.id), "structured_text_options": expected_options}, + lambda client: client.convert_plain_text_to_pdf(source, **options), + ) + + +INVALID_CSV_DELIMITERS = [ + pytest.param("", "at least 1 character", id="empty"), + pytest.param(", ", "at most 1 character", id="trailing-space"), + pytest.param(" ,", "at most 1 character", id="leading-space"), + pytest.param(" \t", "at most 1 character", id="two-whitespace-characters"), +] + + +@pytest.mark.parametrize(("delimiter", "message"), INVALID_CSV_DELIMITERS) +def test_csv_payload_rejects_delimiter_without_trimming( + delimiter: str, message: str +) -> None: + source = _make_format_file(".csv", "text/csv") + with pytest.raises(ValidationError, match=rf"(?s)delimiter.*{message}"): + ConvertCsvToPdfPayload.model_validate({"files": source, "delimiter": delimiter}) + + +@pytest.fixture +def validation_transport() -> httpx.MockTransport: + def fail_transport(_: httpx.Request) -> httpx.Response: + pytest.fail("invalid options must not reach transport") + + return httpx.MockTransport(fail_transport) + + +@pytest.mark.parametrize(("delimiter", "message"), INVALID_CSV_DELIMITERS) +def test_convert_csv_to_pdf_rejects_delimiter_before_transport( + delimiter: str, message: str, validation_transport: httpx.MockTransport +) -> None: + source = _make_format_file(".csv", "text/csv") + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=validation_transport) as client, + pytest.raises(ValidationError, match=rf"(?s)delimiter.*{message}"), + ): + client.convert_csv_to_pdf(source, delimiter=delimiter) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("delimiter", "message"), INVALID_CSV_DELIMITERS) +async def test_async_convert_csv_to_pdf_rejects_delimiter_before_transport( + delimiter: str, message: str, validation_transport: httpx.MockTransport +) -> None: + source = _make_format_file(".csv", "text/csv") + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=validation_transport + ) as client: + with pytest.raises(ValidationError, match=rf"(?s)delimiter.*{message}"): + await client.convert_csv_to_pdf(source, delimiter=delimiter) From 3f8fd3eaa12cef515e05ce989eafed2db44b0774 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 8 Oct 2026 15:35:48 -0500 Subject: [PATCH 14/18] tests: Cover structured numeric and color boundaries Exercise accepted and rejected dimensions, margins, text sizes, heading scales, table borders, padding, column weights, and column indices. Check every RGB channel at its limits and reject invalid tuple lengths. Verify exact payload serialization through distinct sync and async request paths, with invalid inputs rejected before transport execution. Assisted-by: Codex --- ...est_convert_structured_documents_to_pdf.py | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index 7641762..47687f9 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -1485,3 +1485,231 @@ async def test_async_convert_csv_to_pdf_rejects_delimiter_before_transport( ) as client: with pytest.raises(ValidationError, match=rf"(?s)delimiter.*{message}"): await client.convert_csv_to_pdf(source, delimiter=delimiter) + + +def _nested_option(path: tuple[str, ...], value: Any) -> dict[str, Any]: + options: dict[str, Any] = {path[-1]: value} + for key in reversed(path[:-1]): + options = {key: options} + return options + + +def _numeric_options(path: tuple[str, ...], value: float) -> dict[str, Any]: + if path[0] == "columns": + return {"columns": [{"index": 0, path[-1]: value}]} + if path[-1] == "column_width_weights": + return _nested_option(path, [value]) + if path in (("page_setup", "width"), ("page_setup", "height")): + return {"page_setup": {"width": 612, "height": 792, path[-1]: value}} + return _nested_option(path, value) + + +NUMERIC_BOUNDARIES: list[ + tuple[tuple[str, ...], tuple[float, ...], tuple[float, ...], str] +] = [ + (("page_setup", "width"), (0.1, 1), (0, -0.1), "greater than 0"), + (("page_setup", "height"), (0.1, 1), (0, -0.1), "greater than 0"), + *[ + ( + ("page_setup", "margin", side), + (0, 0.1), + (-0.1,), + "greater than or equal to 0", + ) + for side in ("top", "right", "bottom", "left") + ], + ( + ("style", "text_size"), + (6, 7, 71, 72), + (5, 73), + "greater than or equal to 6|less than or equal to 72", + ), + ( + ("style", "heading_scale"), + (0.1, 3.9, 4), + (0, -0.1, 4.1), + "greater than 0|less than or equal to 4", + ), + ( + ("table_style", "border_width"), + (0, 0.1, 11.9, 12), + (-0.1, 12.1), + "greater than or equal to 0|less than or equal to 12", + ), + *[ + ( + ("table_style", "cell_padding", side), + (0, 0.1, 71.9, 72), + (-0.1, 72.1), + "greater than or equal to 0|less than or equal to 72", + ) + for side in ("top", "right", "bottom", "left") + ], + (("table_style", "column_width_weights"), (0.1, 1), (0, -0.1), "greater than 0"), + (("columns", "index"), (0, 1), (-1,), "greater than or equal to 0"), + (("columns", "width_weight"), (0.1, 1), (0, -0.1), "greater than 0"), +] + +VALID_OPTION_BOUNDARIES = [ + pytest.param(_numeric_options(path, value), id=f"{'-'.join(path)}-{value}") + for path, accepted, _, _ in NUMERIC_BOUNDARIES + for value in accepted +] +INVALID_OPTION_BOUNDARIES = [ + pytest.param( + _numeric_options(path, value), + rf"(?s){path[-1]}.*(?:{message})", + id=f"{'-'.join(path)}-{value}", + ) + for path, _, rejected, message in NUMERIC_BOUNDARIES + for value in rejected +] + +RGB_FIELDS = [ + ("style", "text_color_rgb"), + *[ + ("table_style", field) + for field in ( + "border_color_rgb", + "header_fill_color_rgb", + "header_text_color_rgb", + "row_fill_color_rgb", + "alternate_row_fill_color_rgb", + ) + ], +] +VALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option( + path, [value if index == channel else 128 for index in range(3)] + ), + id=f"{'-'.join(path)}-channel-{channel}-{value}", + ) + for path in RGB_FIELDS + for channel in range(3) + for value in (0, 1, 254, 255) +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option( + path, [value if index == channel else 128 for index in range(3)] + ), + rf"(?s){path[-1]}\.{channel}.*(?:greater than or equal to 0|less than or equal to 255)", + id=f"{'-'.join(path)}-channel-{channel}-{value}", + ) + for path in RGB_FIELDS + for channel in range(3) + for value in (-1, 256) +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option(path, [128] * length), + rf"(?s){path[-1]}.*(?:Field required|at most 3 items)", + id=f"{'-'.join(path)}-{length}-channels", + ) + for path in RGB_FIELDS + for length in (2, 4) +) + + +def _boundary_source( + options: dict[str, Any], +) -> tuple[PdfRestFile, type[BaseModel], str]: + if "columns" in options: + return ( + _make_format_file(".csv", "text/csv"), + ConvertCsvToPdfPayload, + "convert_csv_to_pdf", + ) + return ( + _make_format_file(".md", "text/markdown"), + ConvertMarkdownToPdfPayload, + "convert_markdown_to_pdf", + ) + + +def _expected_boundary_payload( + source: PdfRestFile, options: dict[str, Any] +) -> dict[str, Any]: + wire_options = dict(options) + if "table_style" in wire_options: + wire_options["style"] = {"table": wire_options.pop("table_style")} + elif "columns" in wire_options: + wire_options = {"csv": wire_options} + return {"id": str(source.id), "structured_text_options": wire_options} + + +@pytest.mark.parametrize("options", VALID_OPTION_BOUNDARIES) +def test_payload_accepts_declared_constraint_boundaries( + options: dict[str, Any], +) -> None: + source, payload_model, _ = _boundary_source(options) + payload = payload_model.model_validate({"files": source, **options}) + assert _dump_payload(payload) == _expected_boundary_payload(source, options) + + +@pytest.mark.parametrize("options", VALID_OPTION_BOUNDARIES) +def test_sync_accepts_declared_constraint_boundaries( + monkeypatch: pytest.MonkeyPatch, options: dict[str, Any] +) -> None: + source, _, method = _boundary_source(options) + expected = _expected_boundary_payload(source, options) + _sync_conversion( + monkeypatch, + source, + expected, + lambda client: getattr(client, method)(source, **options), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("options", VALID_OPTION_BOUNDARIES) +async def test_async_accepts_declared_constraint_boundaries( + monkeypatch: pytest.MonkeyPatch, options: dict[str, Any] +) -> None: + source, _, method = _boundary_source(options) + expected = _expected_boundary_payload(source, options) + await _async_conversion( + monkeypatch, + source, + expected, + lambda client: getattr(client, method)(source, **options), + ) + + +@pytest.mark.parametrize(("options", "message"), INVALID_OPTION_BOUNDARIES) +def test_payload_rejects_declared_constraint_boundaries( + options: dict[str, Any], message: str +) -> None: + source, payload_model, _ = _boundary_source(options) + with pytest.raises(ValidationError, match=message): + payload_model.model_validate({"files": source, **options}) + + +@pytest.mark.parametrize(("options", "message"), INVALID_OPTION_BOUNDARIES) +def test_sync_rejects_declared_constraint_boundaries_before_transport( + options: dict[str, Any], + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source, _, method = _boundary_source(options) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=validation_transport) as client, + pytest.raises(ValidationError, match=message), + ): + getattr(client, method)(source, **options) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("options", "message"), INVALID_OPTION_BOUNDARIES) +async def test_async_rejects_declared_constraint_boundaries_before_transport( + options: dict[str, Any], + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source, _, method = _boundary_source(options) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=validation_transport + ) as client: + with pytest.raises(ValidationError, match=message): + await getattr(client, method)(source, **options) From 5ebce31ed9e51f620e070c2d746a899ddc64b9e5 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 8 Oct 2026 15:36:12 -0500 Subject: [PATCH 15/18] tests: Cover structured text and collection boundaries Exercise minimum text lengths, whitespace rejection, and nonempty collection bounds through payloads and both client transports. Verify exact output and image alternative text serialization. Reject empty or multiple sources for every structured document format before transport execution. Cover image index and image ID list bounds on the internal Markdown payload contract. Assisted-by: Codex --- ...est_convert_structured_documents_to_pdf.py | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index 47687f9..932f3ab 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -1611,6 +1611,72 @@ def _numeric_options(path: tuple[str, ...], value: float) -> dict[str, Any]: for length in (2, 4) ) +COLLECTION_BOUNDARIES = [ + (("table_style", "column_width_weights"), 1), + (("style", "fallback_fonts"), "Arial"), + (("columns",), {"index": 0}), +] +VALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option(path, [item] * length), + id=f"{'-'.join(path)}-{length}-items", + ) + for path, item in COLLECTION_BOUNDARIES + for length in (1, 2) +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _nested_option(path, []), + rf"(?s){path[-1]}.*at least 1 item", + id=f"{'-'.join(path)}-empty", + ) + for path, _ in COLLECTION_BOUNDARIES +) + +TEXT_BOUNDARIES = [ + ("title",), + ("language",), + *[ + ("style", field) + for field in ("font", "heading_font", "code_font", "cjk_font", "fallback_fonts") + ], + ("image_alt_text",), +] + + +def _text_options(path: tuple[str, ...], text: str) -> dict[str, Any]: + value: Any = text + if path[-1] == "fallback_fonts": + value = [text] + elif path[-1] == "image_alt_text": + value = {"logo": text} + return _nested_option(path, value) + + +VALID_OPTION_BOUNDARIES.extend( + pytest.param( + _text_options(path, text), id=f"{'-'.join(path)}-{len(text)}-characters" + ) + for path in TEXT_BOUNDARIES + for text in ("A", "AB") +) +VALID_OPTION_BOUNDARIES.extend( + pytest.param({"output": output}, id=f"output-{len(output)}-characters") + for output in ("A", "AB") +) +INVALID_OPTION_BOUNDARIES.extend( + pytest.param( + _text_options(path, text), + rf"(?s){path[-1]}.*at least 1 (?:character|item)", + id=f"{'-'.join(path)}-{'empty' if not text else 'whitespace'}", + ) + for path in TEXT_BOUNDARIES + for text in (("",) if path[-1] == "image_alt_text" else ("", " \t ")) +) +INVALID_OPTION_BOUNDARIES.append( + pytest.param({"output": ""}, r"(?s)output.*at least 1 character", id="output-empty") +) + def _boundary_source( options: dict[str, Any], @@ -1631,11 +1697,15 @@ def _boundary_source( def _expected_boundary_payload( source: PdfRestFile, options: dict[str, Any] ) -> dict[str, Any]: + if "output" in options: + return {"id": str(source.id), "output": options["output"]} wire_options = dict(options) if "table_style" in wire_options: wire_options["style"] = {"table": wire_options.pop("table_style")} elif "columns" in wire_options: wire_options = {"csv": wire_options} + elif "image_alt_text" in wire_options: + wire_options = {"markdown": wire_options} return {"id": str(source.id), "structured_text_options": wire_options} @@ -1713,3 +1783,157 @@ async def test_async_rejects_declared_constraint_boundaries_before_transport( ) as client: with pytest.raises(ValidationError, match=message): await getattr(client, method)(source, **options) + + +STRUCTURED_FORMATS = [ + pytest.param( + ConvertMarkdownToPdfPayload, + "convert_markdown_to_pdf", + ".md", + "text/markdown", + id="markdown", + ), + pytest.param( + ConvertPlainTextToPdfPayload, + "convert_plain_text_to_pdf", + ".txt", + "text/plain", + id="plain-text", + ), + pytest.param( + ConvertJsonToPdfPayload, + "convert_json_to_pdf", + ".json", + "application/json", + id="json", + ), + pytest.param( + ConvertXmlToPdfPayload, + "convert_xml_to_pdf", + ".xml", + "application/xml", + id="xml", + ), + pytest.param( + ConvertCsvToPdfPayload, + "convert_csv_to_pdf", + ".csv", + "text/csv", + id="csv", + ), +] +INVALID_SOURCE_COUNTS = [ + pytest.param(0, "at least 1 item", id="empty"), + pytest.param(2, "at most 1 item", id="multiple"), +] + + +@pytest.mark.parametrize( + ("payload_model", "_method", "extension", "mime_type"), STRUCTURED_FORMATS +) +@pytest.mark.parametrize(("count", "message"), INVALID_SOURCE_COUNTS) +def test_payload_rejects_source_cardinality_bounds( + payload_model: type[BaseModel], + _method: str, + extension: str, + mime_type: str, + count: int, + message: str, +) -> None: + source = _make_format_file(extension, mime_type) + with pytest.raises(ValidationError, match=rf"(?s)files.*{message}"): + payload_model.model_validate({"files": [source] * count}) + + +@pytest.mark.parametrize( + ("_payload_model", "method", "extension", "mime_type"), STRUCTURED_FORMATS +) +@pytest.mark.parametrize(("count", "message"), INVALID_SOURCE_COUNTS) +def test_sync_rejects_source_cardinality_bounds_before_transport( + _payload_model: type[BaseModel], + method: str, + extension: str, + mime_type: str, + count: int, + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source = _make_format_file(extension, mime_type) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=validation_transport) as client, + pytest.raises(ValidationError, match=rf"(?s)files.*{message}"), + ): + getattr(client, method)([source] * count) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("_payload_model", "method", "extension", "mime_type"), STRUCTURED_FORMATS +) +@pytest.mark.parametrize(("count", "message"), INVALID_SOURCE_COUNTS) +async def test_async_rejects_source_cardinality_bounds_before_transport( + _payload_model: type[BaseModel], + method: str, + extension: str, + mime_type: str, + count: int, + message: str, + validation_transport: httpx.MockTransport, +) -> None: + source = _make_format_file(extension, mime_type) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=validation_transport + ) as client: + with pytest.raises(ValidationError, match=rf"(?s)files.*{message}"): + await getattr(client, method)([source] * count) + + +@pytest.mark.parametrize( + "index", [pytest.param(0, id="minimum"), pytest.param(1, id="inside")] +) +def test_markdown_image_index_accepts_nonnegative_boundaries(index: int) -> None: + source = _make_format_file(".md", "text/markdown") + options = {"markdown": {"image_sources": {"logo": {"image_id_index": index}}}} + payload = ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "structured_text_options": options} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "structured_text_options": options, + } + + +def test_markdown_image_index_rejects_negative_boundary() -> None: + source = _make_format_file(".md", "text/markdown") + with pytest.raises( + ValidationError, match=r"(?s)image_id_index.*greater than or equal to 0" + ): + ConvertMarkdownToPdfPayload.model_validate( + { + "files": source, + "structured_text_options": { + "markdown": {"image_sources": {"logo": {"image_id_index": -1}}} + }, + } + ) + + +@pytest.mark.parametrize( + "count", [pytest.param(1, id="minimum"), pytest.param(2, id="inside")] +) +def test_markdown_image_ids_accept_nonempty_boundaries(count: int) -> None: + source = _make_format_file(".md", "text/markdown") + image = make_image_file(str(PdfRestFileID.generate(2)), "image/png", "logo.png") + payload = ConvertMarkdownToPdfPayload.model_validate( + {"files": source, "image_ids": [image] * count} + ) + assert _dump_payload(payload) == { + "id": str(source.id), + "image_ids": [str(image.id)] * count, + } + + +def test_markdown_image_ids_reject_empty_list() -> None: + source = _make_format_file(".md", "text/markdown") + with pytest.raises(ValidationError, match=r"(?s)image_ids.*at least 1 item"): + ConvertMarkdownToPdfPayload.model_validate({"files": source, "image_ids": []}) From fa6ba232ec6ae6ea4f09390048b3cc8da505bf8a Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 9 Oct 2026 15:33:52 -0500 Subject: [PATCH 16/18] models: Restore structured page-size whitespace normalization Normalize padded named sizes before literal validation. Keep payload, sync/async request, and live page-size regressions with the fix. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 5 ++++- .../test_live_convert_structured_documents_to_pdf.py | 9 +++++---- tests/test_convert_structured_documents_to_pdf.py | 5 +++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index cd13f58..88a2721 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -973,7 +973,10 @@ class _StructuredTextMargin(_StrictStructuredTextModel): class _StructuredTextPageSetup(_StrictStructuredTextModel): - size: PdfStructuredTextPageSize | None = None + size: Annotated[ + PdfStructuredTextPageSize | None, + BeforeValidator(_strip_structured_string), + ] = None width: Annotated[float | None, Field(gt=0)] = None height: Annotated[float | None, Field(gt=0)] = None orientation: PdfStructuredTextPageOrientation | None = None 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 ec706be..cc28157 100644 --- a/tests/live/test_live_convert_structured_documents_to_pdf.py +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -22,6 +22,7 @@ NAMED_PAGE_SIZES = [ pytest.param("Letter", id="letter"), + pytest.param(" \tLetter\n ", id="padded-letter"), pytest.param("Legal", id="legal"), pytest.param("Ledger", id="ledger"), pytest.param("A3", id="a3"), @@ -176,11 +177,11 @@ def test_live_convert_plain_text_to_pdf_page_size( lambda client: client.convert_plain_text_to_pdf( source, page_setup={"size": size}, - output=f"live-plain-text-page-size-{size.lower()}", + output=f"live-plain-text-page-size-{size.strip().lower()}", ), ) _assert_structured_pdf( - response, source, f"live-plain-text-page-size-{size.lower()}" + response, source, f"live-plain-text-page-size-{size.strip().lower()}" ) @@ -449,11 +450,11 @@ async def test_live_async_convert_plain_text_to_pdf_page_size( lambda client: client.convert_plain_text_to_pdf( source, page_setup={"size": size}, - output=f"live-plain-text-async-page-size-{size.lower()}", + output=f"live-plain-text-async-page-size-{size.strip().lower()}", ), ) _assert_structured_pdf( - response, source, f"live-plain-text-async-page-size-{size.lower()}" + response, source, f"live-plain-text-async-page-size-{size.strip().lower()}" ) diff --git a/tests/test_convert_structured_documents_to_pdf.py b/tests/test_convert_structured_documents_to_pdf.py index 932f3ab..b5e4c72 100644 --- a/tests/test_convert_structured_documents_to_pdf.py +++ b/tests/test_convert_structured_documents_to_pdf.py @@ -1376,6 +1376,11 @@ async def test_async_convert_markdown_to_pdf_preserves_alt_text( TRIMMED_TEXT_OPTIONS = [ + pytest.param( + {"page_setup": {"size": " \tLetter\n "}}, + {"page_setup": {"size": "Letter"}}, + id="page-size", + ), pytest.param({"title": " Report "}, {"title": "Report"}, id="title"), pytest.param({"language": " en-US "}, {"language": "en-US"}, id="language"), *[ From 3e09db4e28f0249cf1b6beb3f2bad765a021301a Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 9 Oct 2026 15:34:14 -0500 Subject: [PATCH 17/18] tests: Cover structured conversion numeric bounds live Derive numeric and RGB cases from payload schemas and exercise accepted and rejected values through both transports. Cover CSV column options and image indices with deterministic table and two-image fixtures. Assisted-by: Codex --- ...ive_convert_structured_documents_to_pdf.py | 315 ++++++++++++++++++ tests/resources/structured-document-table.md | 5 + .../structured-document-with-two-images.md | 5 + 3 files changed, 325 insertions(+) create mode 100644 tests/resources/structured-document-table.md create mode 100644 tests/resources/structured-document-with-two-images.md 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 cc28157..82a5ebc 100644 --- a/tests/live/test_live_convert_structured_documents_to_pdf.py +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -2,11 +2,13 @@ from collections.abc import Awaitable, Callable from pathlib import Path +from typing import Any import pytest from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse +from pdfrest.models._internal import ConvertCsvToPdfPayload, ConvertMarkdownToPdfPayload from pdfrest.types import ( PdfStructuredTextCsvColumn, PdfStructuredTextDataPresentation, @@ -45,11 +47,16 @@ def uploaded_structured_documents( resources = { "markdown": get_test_resource_path("structured-document.md"), "markdown_image": get_test_resource_path("structured-document-with-image.md"), + "markdown_table": get_test_resource_path("structured-document-table.md"), + "markdown_two_images": get_test_resource_path( + "structured-document-with-two-images.md" + ), "plain_text": get_test_resource_path("structured-document.txt"), "json": get_test_resource_path("structured-document.json"), "xml": get_test_resource_path("structured-document.xml"), "csv": get_test_resource_path("structured-document.csv"), "image": get_test_resource_path("test.png"), + "second_image": get_test_resource_path("test.jpg"), } with PdfRestClient( api_key=pdfrest_api_key, @@ -610,3 +617,311 @@ async def test_live_async_convert_json_to_pdf_rejects_invalid_option( "structured_text_options": {"data_presentation": "diorama"} }, ) + + +def _numeric_schema_fields( + node: dict[str, Any], + definitions: dict[str, Any], + path: tuple[str | int, ...] = (), +) -> dict[tuple[str | int, ...], dict[str, Any]]: + """Read numeric constraints from payload schemas, including RGB channels.""" + if "$ref" in node: + node = definitions[node["$ref"].rsplit("/", 1)[-1]] + if "anyOf" in node: + node = next(branch for branch in node["anyOf"] if branch.get("type") != "null") + return _numeric_schema_fields(node, definitions, path) + if node.get("type") in ("number", "integer"): + return {path: node} + fields: dict[tuple[str | int, ...], dict[str, Any]] = {} + for name, child in node.get("properties", {}).items(): + fields.update(_numeric_schema_fields(child, definitions, (*path, name))) + if "items" in node: + fields.update(_numeric_schema_fields(node["items"], definitions, (*path, 0))) + for index, child in enumerate(node.get("prefixItems", [])): + fields.update(_numeric_schema_fields(child, definitions, (*path, index))) + return fields + + +def _numeric_live_cases() -> tuple[list[Any], list[Any]]: + fields: dict[tuple[str | int, ...], dict[str, Any]] = {} + for model in (ConvertMarkdownToPdfPayload, ConvertCsvToPdfPayload): + schema = model.model_json_schema() + fields.update( + _numeric_schema_fields( + schema["properties"]["structured_text_options"], schema["$defs"] + ) + ) + accepted: list[Any] = [] + rejected: list[Any] = [] + for path, field in fields.items(): + step = 1 if field["type"] == "integer" else 0.1 + minimum = field["minimum"] if "minimum" in field else field["exclusiveMinimum"] + valid = [minimum + step] + invalid = [minimum - step] + if "minimum" in field: + valid.insert(0, minimum) + else: + valid.append(minimum + 1) + invalid.append(minimum) + if "maximum" in field: + valid.extend([field["maximum"] - step, field["maximum"]]) + invalid.append(field["maximum"] + step) + label = "-".join(map(str, path)) + for value in valid: + # The service requires usable page area beyond its positive bounds. + layout_rejection = ( + path in (("page_setup", "width"), ("page_setup", "height")) + and value < 180 + ) + accepted.append( + pytest.param(path, value, layout_rejection, id=f"{label}-{value}") + ) + rejected.extend( + pytest.param(path, value, id=f"{label}-{value}") for value in invalid + ) + return accepted, rejected + + +VALID_NUMERIC_LIVE_CASES, INVALID_NUMERIC_LIVE_CASES = _numeric_live_cases() + + +def _numeric_live_options( + path: tuple[str | int, ...], value: float +) -> tuple[str, dict[str, Any], dict[str, Any]]: + """Change one numeric option while keeping related fields valid.""" + options: dict[str, Any] = {} + if path[0] == "csv": + options["csv"] = {"columns": [{"index": 0}]} + if path in (("page_setup", "width"), ("page_setup", "height")): + options["page_setup"] = {"width": 612, "height": 792} + if isinstance(path[-1], int): + if path[-2] == "column_width_weights": + options["style"] = {"table": {"column_width_weights": [1, 1]}} + else: + options["style"] = {} + style = options["style"] + if path[1] == "table": + style["table"] = {} + style = style["table"] + style[path[-2]] = [128, 128, 128] + current: Any = options + for key in path[:-1]: + if isinstance(current, dict): + current = current.setdefault(key, {}) + else: + current = current[key] + current[path[-1]] = value + arguments = dict(options) + if "csv" in arguments: + arguments.update(arguments.pop("csv")) + if "table" in arguments.get("style", {}): + arguments["style"] = dict(arguments["style"]) + arguments["table_style"] = arguments["style"].pop("table") + source_name = "csv" if path[0] == "csv" else "markdown_table" + return source_name, options, arguments + + +@pytest.mark.parametrize( + ("path", "value", "layout_rejection"), VALID_NUMERIC_LIVE_CASES +) +def test_live_structured_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, + layout_rejection: bool, +) -> None: + source_name, _, arguments = _numeric_live_options(path, value) + source = uploaded_structured_documents[source_name] + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + method = ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + ) + if layout_rejection: + with pytest.raises( + PdfRestApiError, match=r"(?i)issue processing|usable width" + ): + method(source, **arguments) + else: + response = method(source, **arguments, output="live-numeric-boundary") + _assert_structured_pdf(response, source, "live-numeric-boundary") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "value", "layout_rejection"), VALID_NUMERIC_LIVE_CASES +) +async def test_live_async_structured_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, + layout_rejection: bool, +) -> None: + source_name, _, arguments = _numeric_live_options(path, value) + source = uploaded_structured_documents[source_name] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + method = ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + ) + if layout_rejection: + with pytest.raises( + PdfRestApiError, match=r"(?i)issue processing|usable width" + ): + await method(source, **arguments) + else: + response = await method( + source, **arguments, output="live-async-numeric-boundary" + ) + _assert_structured_pdf(response, source, "live-async-numeric-boundary") + + +@pytest.mark.parametrize(("path", "value"), INVALID_NUMERIC_LIVE_CASES) +def test_live_structured_rejects_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, +) -> None: + source_name, options, _ = _numeric_live_options(path, value) + with ( + PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)structured_text_options|invalid"), + ): + ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + )( + uploaded_structured_documents[source_name], + extra_body={"structured_text_options": options}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("path", "value"), INVALID_NUMERIC_LIVE_CASES) +async def test_live_async_structured_rejects_numeric_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + path: tuple[str | int, ...], + value: float, +) -> None: + source_name, options, _ = _numeric_live_options(path, value) + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + method = ( + client.convert_csv_to_pdf + if source_name == "csv" + else client.convert_markdown_to_pdf + ) + with pytest.raises( + PdfRestApiError, match=r"(?i)structured_text_options|invalid" + ): + await method( + uploaded_structured_documents[source_name], + extra_body={"structured_text_options": options}, + ) + + +IMAGE_INDEX_BOUNDARIES = [ + pytest.param(0, True, id="minimum"), + pytest.param(1, True, id="inside"), + pytest.param(-1, False, id="below-minimum"), + pytest.param(2, False, id="outside-uploaded-images"), +] + + +def _image_index_options(image_index: int) -> dict[str, Any]: + return { + "markdown": { + "image_sources": { + "company-logo": {"image_id_index": image_index}, + "second-logo": {"image_id_index": 1 if image_index != 1 else 0}, + }, + "image_alt_text": { + "company-logo": "Company logo", + "second-logo": "Second logo", + }, + } + } + + +@pytest.mark.parametrize(("image_index", "valid"), IMAGE_INDEX_BOUNDARIES) +def test_live_markdown_image_index_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + image_index: int, + valid: bool, +) -> None: + source = uploaded_structured_documents["markdown_two_images"] + image = uploaded_structured_documents["image"] + second_image = uploaded_structured_documents["second_image"] + # Wire indices are internal: extra_body is required to exercise their bounds. + extra_body = { + "image_ids": [str(image.id), str(second_image.id)], + "structured_text_options": _image_index_options(image_index), + } + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + if valid: + response = client.convert_markdown_to_pdf( + source, extra_body=extra_body, output="live-image-index" + ) + _assert_structured_pdf(response, source, "live-image-index") + assert response.input_ids == [source.id, image.id, second_image.id] + else: + with pytest.raises( + PdfRestApiError, + match=r"(?i)image_id_index|image.ids|structured_text_options", + ): + client.convert_markdown_to_pdf(source, extra_body=extra_body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("image_index", "valid"), IMAGE_INDEX_BOUNDARIES) +async def test_live_async_markdown_image_index_boundaries( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_structured_documents: dict[str, PdfRestFile], + image_index: int, + valid: bool, +) -> None: + source = uploaded_structured_documents["markdown_two_images"] + image = uploaded_structured_documents["image"] + second_image = uploaded_structured_documents["second_image"] + extra_body = { + "image_ids": [str(image.id), str(second_image.id)], + "structured_text_options": _image_index_options(image_index), + } + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + if valid: + response = await client.convert_markdown_to_pdf( + source, extra_body=extra_body, output="live-async-image-index" + ) + _assert_structured_pdf(response, source, "live-async-image-index") + assert response.input_ids == [source.id, image.id, second_image.id] + else: + with pytest.raises( + PdfRestApiError, + match=r"(?i)image_id_index|image.ids|structured_text_options", + ): + await client.convert_markdown_to_pdf(source, extra_body=extra_body) diff --git a/tests/resources/structured-document-table.md b/tests/resources/structured-document-table.md new file mode 100644 index 0000000..79928e2 --- /dev/null +++ b/tests/resources/structured-document-table.md @@ -0,0 +1,5 @@ +# Boundary test + +| A | B | +| --- | --- | +| 1 | 2 | diff --git a/tests/resources/structured-document-with-two-images.md b/tests/resources/structured-document-with-two-images.md new file mode 100644 index 0000000..8d35f66 --- /dev/null +++ b/tests/resources/structured-document-with-two-images.md @@ -0,0 +1,5 @@ +# Image index boundaries + +![Company logo](company-logo) + +![Second logo](second-logo) From bafcf62632afe634dfb63422efc6277f0b1a87be Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Fri, 9 Oct 2026 15:34:25 -0500 Subject: [PATCH 18/18] AGENTS: Require public disclosure checks for repository content Keep repository content and review communication suitable for public sharing. Describe blocked delimiter coverage through observable service behavior and limit work-item references to the primary PR item. Assisted-by: Codex --- AGENTS.md | 11 ++++++++++- .../test_live_convert_structured_documents_to_pdf.py | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc66308..0a031a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,7 +437,16 @@ ## Commit & Pull Request Guidelines -- Follow the `area: summary` convention seen in `pdfassistant-chatbot` (e.g., +- This is a public repository. Keep source, tests, examples, documentation, + commit messages, PR descriptions, and review comments suitable for public + disclosure. Do not name or link private components, implementation classes, + repositories, infrastructure, internal hosts, or unrelated Jira work items. +- Describe service limitations using observable API behavior. For blocked live + coverage, document the affected cases and the steps to re-enable them without + exposing private implementation details. Reference only the PR's primary Jira + work item unless the user explicitly authorizes another reference and confirms + it is suitable for public disclosure. +- Follow the `area: summary` convention (e.g., `client: Add document merge service`). - Name the commit scope after the primary file, directory, or domain object affected by the change, such as `AGENTS`, `pdfrest-client-api`, `client`, diff --git a/tests/live/test_live_convert_structured_documents_to_pdf.py b/tests/live/test_live_convert_structured_documents_to_pdf.py index 82a5ebc..72500b1 100644 --- a/tests/live/test_live_convert_structured_documents_to_pdf.py +++ b/tests/live/test_live_convert_structured_documents_to_pdf.py @@ -34,8 +34,8 @@ ] WHITESPACE_DELIMITER_SKIP_REASON = ( - "Blocked by clu-structured-text-to-pdf CsvAdapter.ResolveDelimiter(): " - "space/tab delimiters fall back to comma. Enable after the CLU fix is deployed." + "Blocked by service behavior: explicit space/tab CSV delimiters are ignored. " + "Enable after parsing behavior is corrected and deployed." )