From c8d2cd1a18d101e120b5cc311cc82e79888c447e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 22 Aug 2026 17:55:41 -0500 Subject: [PATCH 01/10] client: Add PDF shape overlays - Add sync and async shape helpers with typed line and rectangle inputs - Validate shape coordinates, colors, tagging, and PDF resources - Cover JSON serialization, live service behavior, and API guide discovery Assisted-by: Codex --- docs/api-guide.md | 3 +- src/pdfrest/client.py | 108 ++++++ src/pdfrest/models/_internal.py | 147 +++++++++ src/pdfrest/types/__init__.py | 8 + src/pdfrest/types/public.py | 75 +++++ tests/live/test_live_add_shapes_to_pdf.py | 151 +++++++++ tests/test_add_shapes_to_pdf.py | 384 ++++++++++++++++++++++ 7 files changed, 875 insertions(+), 1 deletion(-) create mode 100644 tests/live/test_live_add_shapes_to_pdf.py create mode 100644 tests/test_add_shapes_to_pdf.py diff --git a/docs/api-guide.md b/docs/api-guide.md index 838170f..aacfafa 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -81,7 +81,8 @@ Use this group to add visible content or remove sensitive content. - Add overlays: [add_text_to_pdf][pdfrest.PdfRestClient.add_text_to_pdf], - [add_image_to_pdf][pdfrest.PdfRestClient.add_image_to_pdf] + [add_image_to_pdf][pdfrest.PdfRestClient.add_image_to_pdf], + [add_shapes_to_pdf][pdfrest.PdfRestClient.add_shapes_to_pdf] - Watermarking: [watermark_pdf_with_text][pdfrest.PdfRestClient.watermark_pdf_with_text], [watermark_pdf_with_image][pdfrest.PdfRestClient.watermark_pdf_with_image] diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 7c66e19..dd1cc72 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -91,6 +91,7 @@ OcrPdfPayload, PdfAddAttachmentPayload, PdfAddImagePayload, + PdfAddShapesPayload, PdfAddTextPayload, PdfBlankPayload, PdfCompressPayload, @@ -144,6 +145,7 @@ HtmlWebLayout, JpegColorModel, OcrLanguage, + PdfAddShapeObject, PdfAddTextObject, PdfAType, PdfConversionCompression, @@ -3453,6 +3455,59 @@ def add_text_to_pdf( timeout=timeout, ) + def add_shapes_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + shape_objects: PdfAddShapeObject | Sequence[PdfAddShapeObject], + tag_enabled: bool | 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: + """Draw one or more lines or rectangles onto a PDF. + + Coordinates use PDF units with the origin in the lower-left corner. + Set ``tag_enabled=True`` when any shape includes tagging metadata. + + Args: + file: Uploaded PDF file as a `PdfRestFile` object. + shape_objects: Line or rectangle objects to draw onto the document. + tag_enabled: Enable tagging for newly added shapes. + output: Output filename prefix used by pdfRest when creating files. + 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 returned by pdfRest. + + 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, + "shape_objects": shape_objects, + } + if tag_enabled is not None: + payload["tag_enabled"] = tag_enabled + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-added-shapes", + payload=payload, + payload_model=PdfAddShapesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def add_image_to_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -6448,6 +6503,59 @@ async def add_text_to_pdf( timeout=timeout, ) + async def add_shapes_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + shape_objects: PdfAddShapeObject | Sequence[PdfAddShapeObject], + tag_enabled: bool | 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.add_shapes_to_pdf][pdfrest.PdfRestClient.add_shapes_to_pdf]. + + Coordinates use PDF units with the origin in the lower-left corner. + Set ``tag_enabled=True`` when any shape includes tagging metadata. + + Args: + file: Uploaded PDF file as a `PdfRestFile` object. + shape_objects: Line or rectangle objects to draw onto the document. + tag_enabled: Enable tagging for newly added shapes. + output: Output filename prefix used by pdfRest when creating files. + 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 returned by pdfRest. + + 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, + "shape_objects": shape_objects, + } + if tag_enabled is not None: + payload["tag_enabled"] = tag_enabled + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-added-shapes", + payload=payload, + payload_model=PdfAddShapesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def add_image_to_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 50d434e..628a62d 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -29,6 +29,7 @@ HtmlWebLayout, OcrLanguage, PdfAType, + PdfContentStructureType, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -240,6 +241,10 @@ def _serialize_text_objects(value: list[BaseModel]) -> str: return to_json(payload).decode() +def _serialize_shape_objects(value: list[BaseModel]) -> list[dict[str, Any]]: + return [entry.model_dump(mode="json", exclude_none=True) for entry in value] + + def _serialize_signature_configuration( value: _PdfSignatureConfigurationModel, ) -> str: @@ -1828,6 +1833,148 @@ class PdfAddTextPayload(BaseModel): ] = None +class _PdfAddedShapeBaseModel(BaseModel): + """Shared validation and serialization for shapes added to PDFs.""" + + model_config = ConfigDict(extra="forbid") + + page: Annotated[ + Literal["all"] | Annotated[int, Field(ge=1)], + Field(serialization_alias="page"), + ] + opacity: Annotated[ + float | None, + Field(serialization_alias="opacity", ge=0.0, le=1.0, default=None), + ] = None + stroke_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field(serialization_alias="stroke_color_rgb", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + stroke_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field(serialization_alias="stroke_color_cmyk", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + stroke_width: Annotated[ + float | None, + Field(serialization_alias="stroke_width", gt=0, default=None), + ] = None + tag_actual_text: Annotated[ + str | None, + Field(serialization_alias="tag_actual_text", min_length=1, default=None), + ] = None + tag_is_artifact: Annotated[ + bool | None, + Field(serialization_alias="tag_is_artifact", default=None), + ] = None + tag_structure_type: Annotated[ + PdfContentStructureType | None, + Field(serialization_alias="tag_structure_type", default=None), + ] = None + + @model_validator(mode="after") + def _ensure_single_stroke_color_option(self) -> _PdfAddedShapeBaseModel: + if self.stroke_color_rgb is not None and self.stroke_color_cmyk is not None: + msg = "Provide only one of stroke_color_rgb or stroke_color_cmyk." + raise ValueError(msg) + return self + + +class PdfAddedLineObjectModel(_PdfAddedShapeBaseModel): + """Adapt a line shape into the pdfRest JSON request contract.""" + + type: Literal["line"] + x1: Annotated[float, Field(ge=0, serialization_alias="x1")] + y1: Annotated[float, Field(ge=0, serialization_alias="y1")] + x2: Annotated[float, Field(ge=0, serialization_alias="x2")] + y2: Annotated[float, Field(ge=0, serialization_alias="y2")] + + +class PdfAddedRectangleObjectModel(_PdfAddedShapeBaseModel): + """Adapt a rectangle shape into the pdfRest JSON request contract.""" + + type: Literal["rectangle"] + x: Annotated[float, Field(ge=0, serialization_alias="x")] + y: Annotated[float, Field(ge=0, serialization_alias="y")] + width: Annotated[float, Field(gt=0, serialization_alias="width")] + height: Annotated[float, Field(gt=0, serialization_alias="height")] + fill_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field(serialization_alias="fill_color_rgb", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + fill_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field(serialization_alias="fill_color_cmyk", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + + @model_validator(mode="after") + def _ensure_single_fill_color_option(self) -> PdfAddedRectangleObjectModel: + if self.fill_color_rgb is not None and self.fill_color_cmyk is not None: + msg = "Provide only one of fill_color_rgb or fill_color_cmyk." + raise ValueError(msg) + return self + + +PdfAddedShapeObjectModel = Annotated[ + PdfAddedLineObjectModel | PdfAddedRectangleObjectModel, + Field(discriminator="type"), +] + + +class PdfAddShapesPayload(BaseModel): + """Adapt caller shape options into a pdfRest-ready add-shapes request payload.""" + + files: 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("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + shape_objects: Annotated[ + list[PdfAddedShapeObjectModel], + Field(serialization_alias="shape_objects", min_length=1), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_shape_objects), + ] + tag_enabled: Annotated[ + bool | None, + Field(serialization_alias="tag_enabled", default=None), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + @model_validator(mode="after") + def _require_tagging_for_shape_metadata(self) -> PdfAddShapesPayload: + has_tag_metadata = any( + shape.tag_actual_text is not None + or shape.tag_is_artifact is not None + or shape.tag_structure_type is not None + for shape in self.shape_objects + ) + if has_tag_metadata and self.tag_enabled is not True: + msg = "tag_enabled must be true when tag options are provided." + raise ValueError(msg) + return self + + class PdfAddImagePayload(BaseModel): """Adapt caller options into a pdfRest-ready add-image request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index f662b0b..5908105 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -16,10 +16,14 @@ HtmlWebLayout, JpegColorModel, OcrLanguage, + PdfAddLineObject, + PdfAddRectangleObject, + PdfAddShapeObject, PdfAddTextObject, PdfAType, PdfCMYKColor, PdfColorProfile, + PdfContentStructureType, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -70,9 +74,13 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddLineObject", + "PdfAddRectangleObject", + "PdfAddShapeObject", "PdfAddTextObject", "PdfCMYKColor", "PdfColorProfile", + "PdfContentStructureType", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 5b7689e..73ce504 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -29,9 +29,13 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddLineObject", + "PdfAddRectangleObject", + "PdfAddShapeObject", "PdfAddTextObject", "PdfCMYKColor", "PdfColorProfile", + "PdfContentStructureType", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", @@ -137,6 +141,77 @@ class PdfRedactionInstruction(TypedDict): PdfRGBColor = tuple[int, int, int] PdfTextColor = PdfRGBColor | PdfCMYKColor +PdfContentStructureType = Literal[ + "P", + "H", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "Lbl", + "Span", + "Quote", + "Note", + "Reference", + "BibEntry", + "Code", + "Link", + "Annot", + "Ruby", + "RB", + "RT", + "RP", + "Warichu", + "WT", + "WP", + "Figure", + "Formula", + "Form", +] + + +class PdfAddLineObject(TypedDict, total=False): + """Line shape inserted by [PdfRestClient.add_shapes_to_pdf][].""" + + type: Required[Literal["line"]] + page: Required[Literal["all"] | int] + x1: Required[float] + y1: Required[float] + x2: Required[float] + y2: Required[float] + stroke_color_rgb: PdfRGBColor | str + stroke_color_cmyk: PdfCMYKColor | str + stroke_width: float + opacity: float + tag_actual_text: str + tag_is_artifact: bool + tag_structure_type: PdfContentStructureType + + +class PdfAddRectangleObject(TypedDict, total=False): + """Rectangle shape inserted by [PdfRestClient.add_shapes_to_pdf][].""" + + type: Required[Literal["rectangle"]] + page: Required[Literal["all"] | int] + x: Required[float] + y: Required[float] + width: Required[float] + height: Required[float] + fill_color_rgb: PdfRGBColor | str + fill_color_cmyk: PdfCMYKColor | str + stroke_color_rgb: PdfRGBColor | str + stroke_color_cmyk: PdfCMYKColor | str + stroke_width: float + opacity: float + tag_actual_text: str + tag_is_artifact: bool + tag_structure_type: PdfContentStructureType + + +PdfAddShapeObject = PdfAddLineObject | PdfAddRectangleObject + class PdfAddTextObject(TypedDict, total=False): """Text overlay object used by add-text style operations. diff --git a/tests/live/test_live_add_shapes_to_pdf.py b/tests/live/test_live_add_shapes_to_pdf.py new file mode 100644 index 0000000..f128e8a --- /dev/null +++ b/tests/live/test_live_add_shapes_to_pdf.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_shape_addition( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([get_test_resource_path("report.pdf")])[0] + + +def _line() -> dict[str, object]: + return { + "type": "line", + "page": 1, + "x1": 72, + "y1": 576, + "x2": 540, + "y2": 576, + "stroke_color_rgb": (26, 72, 112), + "stroke_width": 1.5, + } + + +def _rectangle() -> dict[str, object]: + return { + "type": "rectangle", + "page": "all", + "x": 54, + "y": 540, + "width": 504, + "height": 108, + "fill_color_cmyk": (0, 0, 0, 12), + "opacity": 0.75, + } + + +def test_live_add_shapes_to_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects=[_line(), _rectangle()], + output="live-added-shapes", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.name.startswith("live-added-shapes") + assert output_file.size > 0 + assert response.warning is None + assert uploaded_pdf_for_shape_addition.id in response.input_ids + + +@pytest.mark.asyncio +async def test_live_async_add_shapes_to_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects={ + **_rectangle(), + "tag_actual_text": "Decorative panel", + "tag_structure_type": "Figure", + }, + tag_enabled=True, + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + assert uploaded_pdf_for_shape_addition.id in response.input_ids + + +def test_live_add_shapes_to_pdf_invalid_page( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)page"), + ): + client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects=_line(), + extra_body={ + "shape_objects": [ + { + **_line(), + "page": 0, + "stroke_color_rgb": "26,72,112", + } + ] + }, + ) + + +@pytest.mark.asyncio +async def test_live_async_add_shapes_to_pdf_invalid_page( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)page"): + await client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects=_rectangle(), + extra_body={ + "shape_objects": [ + { + **_rectangle(), + "page": 0, + "fill_color_cmyk": "0,0,0,12", + } + ] + }, + ) diff --git a/tests/test_add_shapes_to_pdf.py b/tests/test_add_shapes_to_pdf.py new file mode 100644 index 0000000..5f45167 --- /dev/null +++ b/tests/test_add_shapes_to_pdf.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import json +import re + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfAddShapesPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_image_file, + make_pdf_file, +) + + +def make_line(**overrides: object) -> dict[str, object]: + line: dict[str, object] = { + "type": "line", + "page": 1, + "x1": 72, + "y1": 576, + "x2": 540, + "y2": 576, + "stroke_color_rgb": (26, 72, 112), + "stroke_width": 1.5, + } + line.update(overrides) + return line + + +def make_rectangle(**overrides: object) -> dict[str, object]: + rectangle: dict[str, object] = { + "type": "rectangle", + "page": "all", + "x": 54, + "y": 540, + "width": 504, + "height": 108, + "fill_color_cmyk": (0, 0, 0, 12), + "opacity": 0.75, + } + rectangle.update(overrides) + return rectangle + + +def test_add_shapes_payload_serializes_line_and_rectangle() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + + payload = PdfAddShapesPayload.model_validate( + { + "files": pdf_file, + "shape_objects": [ + make_line( + tag_actual_text="Section divider", + tag_structure_type="Figure", + ), + make_rectangle(tag_is_artifact=True), + ], + "tag_enabled": True, + "output": "added-shapes", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert payload == { + "id": str(pdf_file.id), + "shape_objects": [ + { + "type": "line", + "page": 1, + "x1": 72.0, + "y1": 576.0, + "x2": 540.0, + "y2": 576.0, + "stroke_color_rgb": "26,72,112", + "stroke_width": 1.5, + "tag_actual_text": "Section divider", + "tag_structure_type": "Figure", + }, + { + "type": "rectangle", + "page": "all", + "x": 54.0, + "y": 540.0, + "width": 504.0, + "height": 108.0, + "fill_color_cmyk": "0,0,0,12", + "opacity": 0.75, + "tag_is_artifact": True, + }, + ], + "tag_enabled": True, + "output": "added-shapes", + } + + +def test_add_shapes_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + expected_payload = PdfAddShapesPayload.model_validate( + { + "files": pdf_file, + "shape_objects": [make_line(), make_rectangle(tag_is_artifact=True)], + "tag_enabled": True, + "output": "with-shapes", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-shapes": + seen["post"] += 1 + assert json.loads(request.content) == expected_payload + return httpx.Response( + 200, json={"inputId": [pdf_file.id], "outputId": [output_id]} + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "with-shapes.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: + response = client.add_shapes_to_pdf( + pdf_file, + shape_objects=[make_line(), make_rectangle(tag_is_artifact=True)], + tag_enabled=True, + output="with-shapes", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "with-shapes.pdf" + assert response.output_file.type == "application/pdf" + assert response.input_id == pdf_file.id + + +@pytest.mark.asyncio +async def test_async_add_shapes_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + seen: dict[str, int] = {"post": 0, "get": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-shapes": + seen["post"] += 1 + payload = json.loads(request.content) + assert payload["id"] == str(pdf_file.id) + assert payload["shape_objects"] == [ + { + "type": "rectangle", + "page": 1, + "x": 10.0, + "y": 20.0, + "width": 30.0, + "height": 40.0, + "fill_color_rgb": "245,247,250", + "tag_actual_text": "Decorative panel", + "tag_structure_type": "Figure", + } + ] + assert payload["tag_enabled"] is True + assert payload["output"] == "async-with-shapes" + return httpx.Response( + 200, json={"inputId": [pdf_file.id], "outputId": [output_id]} + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-with-shapes.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: + response = await client.add_shapes_to_pdf( + pdf_file, + shape_objects=make_rectangle( + page=1, + x=10, + y=20, + width=30, + height=40, + fill_color_rgb=(245, 247, 250), + fill_color_cmyk=None, + opacity=None, + tag_actual_text="Decorative panel", + tag_structure_type="Figure", + ), + tag_enabled=True, + output="async-with-shapes", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_file.name == "async-with-shapes.pdf" + + +def test_add_shapes_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-shapes": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + payload = json.loads(request.content) + assert payload["output"] == "overridden-shapes" + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response( + 200, json={"inputId": [pdf_file.id], "outputId": [output_id]} + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "overridden-shapes.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: + response = client.add_shapes_to_pdf( + pdf_file, + shape_objects=make_line(), + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "1"}, + extra_body={"output": "overridden-shapes"}, + timeout=0.25, + ) + + assert response.output_file.name == "overridden-shapes.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.25) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.25) + + +@pytest.mark.asyncio +async def test_async_add_shapes_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-shapes": + assert request.url.params["trace"] == "true" + assert request.headers["X-Test"] == "async" + assert json.loads(request.content)["output"] == "async-custom-shapes" + return httpx.Response( + 200, json={"inputId": [pdf_file.id], "outputId": [output_id]} + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "true" + assert request.headers["X-Test"] == "async" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-custom-shapes.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: + response = await client.add_shapes_to_pdf( + pdf_file, + shape_objects=make_line(), + extra_query={"trace": "true"}, + extra_headers={"X-Test": "async"}, + extra_body={"output": "async-custom-shapes"}, + timeout=1.0, + ) + + assert response.output_file.name == "async-custom-shapes.pdf" + + +@pytest.mark.parametrize( + ("shape_objects", "tag_enabled", "match"), + [ + pytest.param([], None, "at least 1 item", id="empty-shapes"), + pytest.param( + make_line(stroke_color_cmyk=(0, 0, 0, 100)), + None, + re.escape("Provide only one of stroke_color_rgb or stroke_color_cmyk."), + id="line-color-conflict", + ), + pytest.param( + make_rectangle(fill_color_rgb=(255, 255, 255)), + None, + re.escape("Provide only one of fill_color_rgb or fill_color_cmyk."), + id="rectangle-color-conflict", + ), + pytest.param( + make_rectangle(width=0), + None, + "greater than 0", + id="zero-width", + ), + pytest.param( + make_line(tag_actual_text="Tagged line"), + False, + re.escape("tag_enabled must be true when tag options are provided."), + id="tagging-not-enabled", + ), + ], +) +def test_add_shapes_to_pdf_rejects_invalid_input( + monkeypatch: pytest.MonkeyPatch, + shape_objects: object, + tag_enabled: bool | None, + match: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match=match), + ): + client.add_shapes_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + shape_objects=shape_objects, # type: ignore[arg-type] + tag_enabled=tag_enabled, + ) + + +@pytest.mark.asyncio +async def test_async_add_shapes_to_pdf_rejects_non_pdf( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.add_shapes_to_pdf( + make_image_file(PdfRestFileID.generate(1)), + shape_objects=make_line(), + ) From 0892527baaa75724f3cd7b702df9749212e7b7c8 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 22 Aug 2026 18:28:41 -0500 Subject: [PATCH 02/10] types: Qualify shape API doc links - Resolve MkDocs cross-references for added shape object types - Restore strict documentation build validation Assisted-by: Codex --- src/pdfrest/types/public.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 73ce504..a18d31d 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -173,7 +173,7 @@ class PdfRedactionInstruction(TypedDict): class PdfAddLineObject(TypedDict, total=False): - """Line shape inserted by [PdfRestClient.add_shapes_to_pdf][].""" + """Line shape inserted by [pdfrest.PdfRestClient.add_shapes_to_pdf][].""" type: Required[Literal["line"]] page: Required[Literal["all"] | int] @@ -191,7 +191,7 @@ class PdfAddLineObject(TypedDict, total=False): class PdfAddRectangleObject(TypedDict, total=False): - """Rectangle shape inserted by [PdfRestClient.add_shapes_to_pdf][].""" + """Rectangle shape inserted by [pdfrest.PdfRestClient.add_shapes_to_pdf][].""" type: Required[Literal["rectangle"]] page: Required[Literal["all"] | int] From ce1cf67c5509606756e03b26dc432005796d7e3d Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sat, 22 Aug 2026 19:08:53 -0500 Subject: [PATCH 03/10] tests: Cover shape overlay validation boundaries - Assert async timeout propagation and PDF MIME validation - Reject multiple input PDFs through both client transports - Cover shape geometry, opacity, and color range boundaries Assisted-by: Codex --- tests/test_add_shapes_to_pdf.py | 166 ++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/tests/test_add_shapes_to_pdf.py b/tests/test_add_shapes_to_pdf.py index 5f45167..5e0ee2d 100644 --- a/tests/test_add_shapes_to_pdf.py +++ b/tests/test_add_shapes_to_pdf.py @@ -275,12 +275,14 @@ async def test_async_add_shapes_to_pdf_request_customization( monkeypatch.delenv("PDFREST_API_KEY", raising=False) pdf_file = make_pdf_file(PdfRestFileID.generate(1)) output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, object] = {} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/pdf-with-added-shapes": assert request.url.params["trace"] == "true" assert request.headers["X-Test"] == "async" assert json.loads(request.content)["output"] == "async-custom-shapes" + captured_timeout["value"] = request.extensions.get("timeout") return httpx.Response( 200, json={"inputId": [pdf_file.id], "outputId": [output_id]} ) @@ -309,6 +311,14 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert response.output_file.name == "async-custom-shapes.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(1.0) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(1.0) @pytest.mark.parametrize( @@ -382,3 +392,159 @@ def handler(_: httpx.Request) -> httpx.Response: make_image_file(PdfRestFileID.generate(1)), shape_objects=make_line(), ) + + +def test_add_shapes_to_pdf_rejects_non_pdf( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.add_shapes_to_pdf( + make_image_file(PdfRestFileID.generate(1)), + shape_objects=make_line(), + ) + + +def test_add_shapes_to_pdf_rejects_multiple_input_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match="at most 1 item"), + ): + client.add_shapes_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + shape_objects=make_line(), + ) + + +@pytest.mark.asyncio +async def test_async_add_shapes_to_pdf_rejects_multiple_input_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match="at most 1 item"): + await client.add_shapes_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + shape_objects=make_line(), + ) + + +@pytest.mark.parametrize( + ("shape", "match"), + [ + pytest.param(make_line(x1=-1), "greater than or equal to 0", id="x1-below"), + pytest.param(make_line(y2=-1), "greater than or equal to 0", id="y2-below"), + pytest.param(make_line(page=0), "greater than or equal to 1", id="page-below"), + pytest.param( + make_line(opacity=-0.01), "greater than or equal to 0", id="opacity-below" + ), + pytest.param( + make_line(opacity=1.01), "less than or equal to 1", id="opacity-above" + ), + pytest.param( + make_line(stroke_width=0), "greater than 0", id="stroke-width-zero" + ), + pytest.param( + make_line(stroke_color_rgb=(-1, 0, 0)), + "greater than or equal to 0", + id="rgb-below", + ), + pytest.param( + make_line(stroke_color_rgb=(256, 0, 0)), + "less than or equal to 255", + id="rgb-above", + ), + pytest.param(make_rectangle(width=0), "greater than 0", id="width-zero"), + pytest.param(make_rectangle(height=0), "greater than 0", id="height-zero"), + pytest.param( + make_rectangle(fill_color_cmyk=(-1, 0, 0, 0)), + "greater than or equal to 0", + id="cmyk-below", + ), + pytest.param( + make_rectangle(fill_color_cmyk=(101, 0, 0, 0)), + "less than or equal to 100", + id="cmyk-above", + ), + ], +) +def test_add_shapes_payload_rejects_out_of_range_values( + shape: dict[str, object], + match: str, +) -> None: + with pytest.raises(ValidationError, match=match): + PdfAddShapesPayload.model_validate( + { + "files": make_pdf_file(PdfRestFileID.generate(1)), + "shape_objects": shape, + } + ) + + +@pytest.mark.parametrize( + "shape", + [ + pytest.param( + make_line( + page=1, + x1=0, + y1=0, + x2=0, + y2=0, + opacity=0, + stroke_color_rgb=(0, 255, 0), + ), + id="line-lower-bounds", + ), + pytest.param( + make_rectangle( + x=0, + y=0, + width=0.01, + height=0.01, + opacity=1, + fill_color_cmyk=(0, 100, 0, 100), + ), + id="rectangle-upper-bounds", + ), + ], +) +def test_add_shapes_payload_accepts_boundary_values(shape: dict[str, object]) -> None: + payload = PdfAddShapesPayload.model_validate( + { + "files": make_pdf_file(PdfRestFileID.generate(1)), + "shape_objects": shape, + } + ) + + assert payload.shape_objects[0].model_dump(mode="json", exclude_none=True) From 3fd86ce3ebf443b91fb0979b099349720825ced5 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Sun, 23 Aug 2026 08:27:48 -0500 Subject: [PATCH 04/10] types: Simplify PDF shape color inputs - Accept one RGB or CMYK color field for each shape color - Route colors to pdfRest's RGB or CMYK wire fields by channel count - Update unit and live endpoint coverage Assisted-by: Codex --- src/pdfrest/models/_internal.py | 81 +++++++++++++++-------- src/pdfrest/types/__init__.py | 2 + src/pdfrest/types/public.py | 13 ++-- tests/live/test_live_add_shapes_to_pdf.py | 24 +++++-- tests/test_add_shapes_to_pdf.py | 31 +++++---- 5 files changed, 96 insertions(+), 55 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 628a62d..8a72654 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -136,8 +136,9 @@ def _split_comma_string(value: Any) -> list[Any] | None: raise ValueError(msg) -def _route_text_color_by_channel_count( +def _route_color_by_channel_count( *, + color_name: str, expected_channel_count: int, alternate_channel_count: int, ) -> Callable[[Any], list[Any] | None]: @@ -149,7 +150,7 @@ def _validator(value: Any) -> list[Any] | None: return channels if len(channels) == alternate_channel_count: return None - msg = "text_color must include exactly 3 (RGB) or 4 (CMYK) values." + msg = f"{color_name} must include exactly 3 (RGB) or 4 (CMYK) values." raise ValueError(msg) return _validator @@ -1848,14 +1849,34 @@ class _PdfAddedShapeBaseModel(BaseModel): ] = None stroke_color_rgb: Annotated[ tuple[RgbChannel, RgbChannel, RgbChannel] | None, - Field(serialization_alias="stroke_color_rgb", default=None), - BeforeValidator(_split_comma_string), + Field( + validation_alias="stroke_color", + serialization_alias="stroke_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="stroke_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), PlainSerializer(_serialize_as_comma_separated_string), ] = None stroke_color_cmyk: Annotated[ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, - Field(serialization_alias="stroke_color_cmyk", default=None), - BeforeValidator(_split_comma_string), + Field( + validation_alias="stroke_color", + serialization_alias="stroke_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="stroke_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), PlainSerializer(_serialize_as_comma_separated_string), ] = None stroke_width: Annotated[ @@ -1875,13 +1896,6 @@ class _PdfAddedShapeBaseModel(BaseModel): Field(serialization_alias="tag_structure_type", default=None), ] = None - @model_validator(mode="after") - def _ensure_single_stroke_color_option(self) -> _PdfAddedShapeBaseModel: - if self.stroke_color_rgb is not None and self.stroke_color_cmyk is not None: - msg = "Provide only one of stroke_color_rgb or stroke_color_cmyk." - raise ValueError(msg) - return self - class PdfAddedLineObjectModel(_PdfAddedShapeBaseModel): """Adapt a line shape into the pdfRest JSON request contract.""" @@ -1903,24 +1917,37 @@ class PdfAddedRectangleObjectModel(_PdfAddedShapeBaseModel): height: Annotated[float, Field(gt=0, serialization_alias="height")] fill_color_rgb: Annotated[ tuple[RgbChannel, RgbChannel, RgbChannel] | None, - Field(serialization_alias="fill_color_rgb", default=None), - BeforeValidator(_split_comma_string), + Field( + validation_alias="fill_color", + serialization_alias="fill_color_rgb", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="fill_color", + expected_channel_count=3, + alternate_channel_count=4, + ) + ), PlainSerializer(_serialize_as_comma_separated_string), ] = None fill_color_cmyk: Annotated[ tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, - Field(serialization_alias="fill_color_cmyk", default=None), - BeforeValidator(_split_comma_string), + Field( + validation_alias="fill_color", + serialization_alias="fill_color_cmyk", + default=None, + ), + BeforeValidator( + _route_color_by_channel_count( + color_name="fill_color", + expected_channel_count=4, + alternate_channel_count=3, + ) + ), PlainSerializer(_serialize_as_comma_separated_string), ] = None - @model_validator(mode="after") - def _ensure_single_fill_color_option(self) -> PdfAddedRectangleObjectModel: - if self.fill_color_rgb is not None and self.fill_color_cmyk is not None: - msg = "Provide only one of fill_color_rgb or fill_color_cmyk." - raise ValueError(msg) - return self - PdfAddedShapeObjectModel = Annotated[ PdfAddedLineObjectModel | PdfAddedRectangleObjectModel, @@ -2110,7 +2137,8 @@ class PdfTextWatermarkPayload(_BasePdfWatermarkPayload): default=None, ), BeforeValidator( - _route_text_color_by_channel_count( + _route_color_by_channel_count( + color_name="text_color", expected_channel_count=3, alternate_channel_count=4, ) @@ -2125,7 +2153,8 @@ class PdfTextWatermarkPayload(_BasePdfWatermarkPayload): default=None, ), BeforeValidator( - _route_text_color_by_channel_count( + _route_color_by_channel_count( + color_name="text_color", expected_channel_count=4, alternate_channel_count=3, ) diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 5908105..64cdb0e 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -22,6 +22,7 @@ PdfAddTextObject, PdfAType, PdfCMYKColor, + PdfColor, PdfColorProfile, PdfContentStructureType, PdfConversionCompression, @@ -79,6 +80,7 @@ "PdfAddShapeObject", "PdfAddTextObject", "PdfCMYKColor", + "PdfColor", "PdfColorProfile", "PdfContentStructureType", "PdfConversionCompression", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index a18d31d..3786f36 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -34,6 +34,7 @@ "PdfAddShapeObject", "PdfAddTextObject", "PdfCMYKColor", + "PdfColor", "PdfColorProfile", "PdfContentStructureType", "PdfConversionCompression", @@ -139,7 +140,8 @@ class PdfRedactionInstruction(TypedDict): PdfCMYKColor = tuple[int, int, int, int] PdfRGBColor = tuple[int, int, int] -PdfTextColor = PdfRGBColor | PdfCMYKColor +PdfColor = PdfRGBColor | PdfCMYKColor +PdfTextColor = PdfColor PdfContentStructureType = Literal[ "P", @@ -181,8 +183,7 @@ class PdfAddLineObject(TypedDict, total=False): y1: Required[float] x2: Required[float] y2: Required[float] - stroke_color_rgb: PdfRGBColor | str - stroke_color_cmyk: PdfCMYKColor | str + stroke_color: PdfColor stroke_width: float opacity: float tag_actual_text: str @@ -199,10 +200,8 @@ class PdfAddRectangleObject(TypedDict, total=False): y: Required[float] width: Required[float] height: Required[float] - fill_color_rgb: PdfRGBColor | str - fill_color_cmyk: PdfCMYKColor | str - stroke_color_rgb: PdfRGBColor | str - stroke_color_cmyk: PdfCMYKColor | str + fill_color: PdfColor + stroke_color: PdfColor stroke_width: float opacity: float tag_actual_text: str diff --git a/tests/live/test_live_add_shapes_to_pdf.py b/tests/live/test_live_add_shapes_to_pdf.py index f128e8a..723f03d 100644 --- a/tests/live/test_live_add_shapes_to_pdf.py +++ b/tests/live/test_live_add_shapes_to_pdf.py @@ -28,7 +28,7 @@ def _line() -> dict[str, object]: "y1": 576, "x2": 540, "y2": 576, - "stroke_color_rgb": (26, 72, 112), + "stroke_color": (26, 72, 112), "stroke_width": 1.5, } @@ -41,11 +41,25 @@ def _rectangle() -> dict[str, object]: "y": 540, "width": 504, "height": 108, - "fill_color_cmyk": (0, 0, 0, 12), + "fill_color": (0, 0, 0, 12), "opacity": 0.75, } +def _server_line() -> dict[str, object]: + line = _line() + line["stroke_color_rgb"] = "26,72,112" + del line["stroke_color"] + return line + + +def _server_rectangle() -> dict[str, object]: + rectangle = _rectangle() + rectangle["fill_color_cmyk"] = "0,0,0,12" + del rectangle["fill_color"] + return rectangle + + def test_live_add_shapes_to_pdf( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -116,9 +130,8 @@ def test_live_add_shapes_to_pdf_invalid_page( extra_body={ "shape_objects": [ { - **_line(), + **_server_line(), "page": 0, - "stroke_color_rgb": "26,72,112", } ] }, @@ -142,9 +155,8 @@ async def test_live_async_add_shapes_to_pdf_invalid_page( extra_body={ "shape_objects": [ { - **_rectangle(), + **_server_rectangle(), "page": 0, - "fill_color_cmyk": "0,0,0,12", } ] }, diff --git a/tests/test_add_shapes_to_pdf.py b/tests/test_add_shapes_to_pdf.py index 5e0ee2d..370deb9 100644 --- a/tests/test_add_shapes_to_pdf.py +++ b/tests/test_add_shapes_to_pdf.py @@ -28,7 +28,7 @@ def make_line(**overrides: object) -> dict[str, object]: "y1": 576, "x2": 540, "y2": 576, - "stroke_color_rgb": (26, 72, 112), + "stroke_color": (26, 72, 112), "stroke_width": 1.5, } line.update(overrides) @@ -43,7 +43,7 @@ def make_rectangle(**overrides: object) -> dict[str, object]: "y": 540, "width": 504, "height": 108, - "fill_color_cmyk": (0, 0, 0, 12), + "fill_color": (0, 0, 0, 12), "opacity": 0.75, } rectangle.update(overrides) @@ -201,8 +201,7 @@ def handler(request: httpx.Request) -> httpx.Response: y=20, width=30, height=40, - fill_color_rgb=(245, 247, 250), - fill_color_cmyk=None, + fill_color=(245, 247, 250), opacity=None, tag_actual_text="Decorative panel", tag_structure_type="Figure", @@ -326,16 +325,16 @@ def handler(request: httpx.Request) -> httpx.Response: [ pytest.param([], None, "at least 1 item", id="empty-shapes"), pytest.param( - make_line(stroke_color_cmyk=(0, 0, 0, 100)), + make_line(stroke_color=(0, 0)), None, - re.escape("Provide only one of stroke_color_rgb or stroke_color_cmyk."), - id="line-color-conflict", + re.escape("stroke_color must include exactly 3 (RGB) or 4 (CMYK) values."), + id="line-color-channel-count", ), pytest.param( - make_rectangle(fill_color_rgb=(255, 255, 255)), + make_rectangle(fill_color=(0, 0)), None, - re.escape("Provide only one of fill_color_rgb or fill_color_cmyk."), - id="rectangle-color-conflict", + re.escape("fill_color must include exactly 3 (RGB) or 4 (CMYK) values."), + id="rectangle-color-channel-count", ), pytest.param( make_rectangle(width=0), @@ -475,24 +474,24 @@ def handler(_: httpx.Request) -> httpx.Response: make_line(stroke_width=0), "greater than 0", id="stroke-width-zero" ), pytest.param( - make_line(stroke_color_rgb=(-1, 0, 0)), + make_line(stroke_color=(-1, 0, 0)), "greater than or equal to 0", id="rgb-below", ), pytest.param( - make_line(stroke_color_rgb=(256, 0, 0)), + make_line(stroke_color=(256, 0, 0)), "less than or equal to 255", id="rgb-above", ), pytest.param(make_rectangle(width=0), "greater than 0", id="width-zero"), pytest.param(make_rectangle(height=0), "greater than 0", id="height-zero"), pytest.param( - make_rectangle(fill_color_cmyk=(-1, 0, 0, 0)), + make_rectangle(fill_color=(-1, 0, 0, 0)), "greater than or equal to 0", id="cmyk-below", ), pytest.param( - make_rectangle(fill_color_cmyk=(101, 0, 0, 0)), + make_rectangle(fill_color=(101, 0, 0, 0)), "less than or equal to 100", id="cmyk-above", ), @@ -522,7 +521,7 @@ def test_add_shapes_payload_rejects_out_of_range_values( x2=0, y2=0, opacity=0, - stroke_color_rgb=(0, 255, 0), + stroke_color=(0, 255, 0), ), id="line-lower-bounds", ), @@ -533,7 +532,7 @@ def test_add_shapes_payload_rejects_out_of_range_values( width=0.01, height=0.01, opacity=1, - fill_color_cmyk=(0, 100, 0, 100), + fill_color=(0, 100, 0, 100), ), id="rectangle-upper-bounds", ), From a85c486ec6bda322b0a1a5323bd8a3b76f15e404 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 25 Aug 2026 17:00:23 -0500 Subject: [PATCH 05/10] pyproject: Bump release to 1.1.0 Assisted-by: Codex --- pyproject.toml | 2 +- uv.lock | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c744ad1..2bdc5de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pdfrest" -version = "1.0.4" +version = "1.1.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 78e5987..a48fd04 100644 --- a/uv.lock +++ b/uv.lock @@ -454,6 +454,7 @@ dependencies = [ { name = "griffecli" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/04/56/28a0accac339c164b52a92c6cfc45a903acc0c174caa5c1713803467b533/griffe-2.0.0.tar.gz", hash = "sha256:c68979cd8395422083a51ea7cf02f9c119d889646d99b7b656ee43725de1b80f", size = 293906, upload-time = "2026-03-23T21:06:53.402Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, ] @@ -466,6 +467,7 @@ dependencies = [ { name = "colorama" }, { name = "griffelib" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/a4/f8/2e129fd4a86e52e58eefe664de05e7d502decf766e7316cc9e70fdec3e18/griffecli-2.0.0.tar.gz", hash = "sha256:312fa5ebb4ce6afc786356e2d0ce85b06c1c20d45abc42d74f0cda65e159f6ef", size = 56213, upload-time = "2026-03-23T21:06:54.8Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, ] @@ -474,6 +476,7 @@ wheels = [ name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -961,7 +964,7 @@ wheels = [ [[package]] name = "pdfrest" -version = "1.0.4" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "exceptiongroup" }, From e2d451d3c1213f484e18abe540d0e4c1670812d0 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 25 Aug 2026 17:43:11 -0500 Subject: [PATCH 06/10] types: Document PDF shape object contracts - Describe line and rectangle shape fields for generated API reference - Mark the shape union as a type alias so its union renders correctly Assisted-by: Codex --- src/pdfrest/types/public.py | 55 ++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 3786f36..09c065c 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Literal, cast, get_args +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, get_args from typing_extensions import Required, TypedDict @@ -175,7 +175,27 @@ class PdfRedactionInstruction(TypedDict): class PdfAddLineObject(TypedDict, total=False): - """Line shape inserted by [pdfrest.PdfRestClient.add_shapes_to_pdf][].""" + """Line shape inserted by [pdfrest.PdfRestClient.add_shapes_to_pdf][]. + + Attributes: + type: Required discriminator. Must be ``"line"``. + page: Required one-based page number or ``"all"``. + x1: Required horizontal start coordinate in PDF points. Must be at least 0. + y1: Required vertical start coordinate in PDF points. Must be at least 0. + x2: Required horizontal end coordinate in PDF points. Must be at least 0. + y2: Required vertical end coordinate in PDF points. Must be at least 0. + stroke_color: Optional RGB ``(red, green, blue)`` or CMYK + ``(cyan, magenta, yellow, black)`` tuple. RGB channels range from 0 + through 255; CMYK channels range from 0 through 100. + stroke_width: Optional line width in PDF points. Must be greater than 0. + opacity: Optional opacity from 0 (transparent) through 1 (opaque). + tag_actual_text: Optional non-empty accessible text. Requires + ``tag_enabled=True`` on the client method. + tag_is_artifact: Optional artifact marker. Requires ``tag_enabled=True`` + on the client method. + tag_structure_type: Optional PDF structure type. Requires + ``tag_enabled=True`` on the client method. + """ type: Required[Literal["line"]] page: Required[Literal["all"] | int] @@ -192,7 +212,32 @@ class PdfAddLineObject(TypedDict, total=False): class PdfAddRectangleObject(TypedDict, total=False): - """Rectangle shape inserted by [pdfrest.PdfRestClient.add_shapes_to_pdf][].""" + """Rectangle shape inserted by [pdfrest.PdfRestClient.add_shapes_to_pdf][]. + + Attributes: + type: Required discriminator. Must be ``"rectangle"``. + page: Required one-based page number or ``"all"``. + x: Required horizontal lower-left coordinate in PDF points. Must be at + least 0. + y: Required vertical lower-left coordinate in PDF points. Must be at + least 0. + width: Required width in PDF points. Must be greater than 0. + height: Required height in PDF points. Must be greater than 0. + fill_color: Optional RGB ``(red, green, blue)`` or CMYK + ``(cyan, magenta, yellow, black)`` tuple. RGB channels range from 0 + through 255; CMYK channels range from 0 through 100. + stroke_color: Optional RGB or CMYK tuple with the same channel ranges as + ``fill_color``. + stroke_width: Optional border width in PDF points. Must be greater than + 0. + opacity: Optional opacity from 0 (transparent) through 1 (opaque). + tag_actual_text: Optional non-empty accessible text. Requires + ``tag_enabled=True`` on the client method. + tag_is_artifact: Optional artifact marker. Requires ``tag_enabled=True`` + on the client method. + tag_structure_type: Optional PDF structure type. Requires + ``tag_enabled=True`` on the client method. + """ type: Required[Literal["rectangle"]] page: Required[Literal["all"] | int] @@ -209,7 +254,9 @@ class PdfAddRectangleObject(TypedDict, total=False): tag_structure_type: PdfContentStructureType -PdfAddShapeObject = PdfAddLineObject | PdfAddRectangleObject +PdfAddShapeObject: TypeAlias = PdfAddLineObject | PdfAddRectangleObject +"""A line or rectangle object accepted by +[pdfrest.PdfRestClient.add_shapes_to_pdf][].""" class PdfAddTextObject(TypedDict, total=False): From 466ed374fa398c2f563afc5caf31a647d5d70912 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 25 Aug 2026 17:48:49 -0500 Subject: [PATCH 07/10] conftest: Allow live test base URL override - Prefer PDFREST_LIVE_BASE_URL before the standard endpoint candidates - Document live-test configuration and fallback behavior for contributors Assisted-by: Codex --- CONTRIBUTING.md | 16 ++++++++++++++++ tests/conftest.py | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98d8c17..d07e947 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,22 @@ To reuse existing coverage JSON without rerunning tests: uvx nox -s class-coverage -- --no-tests ``` +### Live tests + +Live tests require `PDFREST_API_KEY`. By default, the test fixture tries the +local service, the development service, and then the production service. To run +against a specific reachable pdfRest deployment first, set +`PDFREST_LIVE_BASE_URL` to its base URL: + +```bash +export PDFREST_API_KEY="..." +export PDFREST_LIVE_BASE_URL="https://pdfrest.example.com" +uvx nox -s tests-3.11 -- tests/live +``` + +If that URL is unavailable, the fixture continues with its normal fallback URLs +and fails only when none are reachable. + ## Examples Run all examples: diff --git a/tests/conftest.py b/tests/conftest.py index e4c4e7a..1be9c0c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,13 @@ def pdfrest_api_key() -> str: def pdfrest_live_base_url(pdfrest_api_key: str) -> str: headers = {"Authorization": f"Bearer {pdfrest_api_key}"} timeout = httpx.Timeout(2.0) - for base_url in LIVE_BASE_URL_CANDIDATES: + configured_base_url = os.getenv("PDFREST_LIVE_BASE_URL") + base_url_candidates = ( + (configured_base_url, *LIVE_BASE_URL_CANDIDATES) + if configured_base_url + else LIVE_BASE_URL_CANDIDATES + ) + for base_url in base_url_candidates: try: with httpx.Client(base_url=base_url, timeout=timeout) as client: response = client.get("/up", headers=headers) From 2d3b335a5b3fd032f4ec3314dff729ac8632aa4f Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 26 Aug 2026 17:26:46 -0500 Subject: [PATCH 08/10] examples: Add shape overlay example - Demonstrate typed rectangle and line construction - Exercise tagging and report output metadata against a bundled PDF Assisted-by: Codex --- examples/README.md | 2 + .../add_shapes/add_shapes_to_pdf_example.py | 83 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 examples/add_shapes/add_shapes_to_pdf_example.py diff --git a/examples/README.md b/examples/README.md index 7657123..b308991 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,6 +21,8 @@ supported interpreter matrix. ## Available Examples +- `examples/add_shapes/add_shapes_to_pdf_example.py` – add a styled rectangle + and divider line to a PDF with accessibility tagging enabled. - `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/add_shapes/add_shapes_to_pdf_example.py b/examples/add_shapes/add_shapes_to_pdf_example.py new file mode 100644 index 0000000..f527ea9 --- /dev/null +++ b/examples/add_shapes/add_shapes_to_pdf_example.py @@ -0,0 +1,83 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Add a styled panel and divider line to a PDF. + +This sample demonstrates how to: + +1. Upload the bundled ``examples/resources/report.pdf`` resource. +2. Describe rectangle and line overlays with typed ``PdfAddShapeObject`` values. +3. Add the shapes to page 1 with accessibility tagging enabled. +4. Print metadata for the PDF returned by pdfRest. + +Set ``PDFREST_API_KEY``, then run from the repository root with +``uv run examples/add_shapes/add_shapes_to_pdf_example.py``. The input PDF is +included in the repository, so no additional input files are required. +""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient +from pdfrest.types import ( + PdfAddLineObject, + PdfAddRectangleObject, + PdfAddShapeObject, +) + +RESOURCE = Path(__file__).resolve().parents[1] / "resources" / "report.pdf" + + +def add_shapes_to_report() -> None: + """Upload the sample report and add a tagged panel and divider line.""" + load_dotenv() + shapes: list[PdfAddShapeObject] = [ + PdfAddRectangleObject( + type="rectangle", + page=1, + x=54, + y=540, + width=504, + height=108, + fill_color=(245, 247, 250), + stroke_color=(26, 72, 112), + stroke_width=1, + tag_is_artifact=True, + ), + PdfAddLineObject( + type="line", + page=1, + x1=72, + y1=510, + x2=540, + y2=510, + stroke_color=(220, 45, 55), + stroke_width=4, + tag_actual_text="Report section divider", + tag_structure_type="Figure", + ), + ] + + with PdfRestClient() as client: + uploaded = client.files.create_from_paths([RESOURCE])[0] + response = client.add_shapes_to_pdf( + uploaded, + shape_objects=shapes, + tag_enabled=True, + output="report-with-shapes", + ) + + output = response.output_file + print(f"Created {output.name}") + print(f"Output ID: {output.id}") + print(f"MIME type: {output.type}") + print(f"Size: {output.size} bytes") + print(f"Download URL: {output.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + add_shapes_to_report() From 8a183687eca4ac00e761cf54f784aa8d4214db28 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 7 Oct 2026 17:43:38 -0500 Subject: [PATCH 09/10] types: Document every PDF content structure type Declare the public literal as a TypeAlias and describe the semantic role of each accepted spelling alongside the tagging requirement. Assisted-by: Codex --- src/pdfrest/types/public.py | 38 ++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 09c065c..9bb02c6 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -143,7 +143,7 @@ class PdfRedactionInstruction(TypedDict): PdfColor = PdfRGBColor | PdfCMYKColor PdfTextColor = PdfColor -PdfContentStructureType = Literal[ +PdfContentStructureType: TypeAlias = Literal[ "P", "H", "H1", @@ -172,6 +172,42 @@ class PdfRedactionInstruction(TypedDict): "Formula", "Form", ] +"""Semantic PDF structure type assigned to newly added content. + +Shape tagging requires ``tag_enabled=True`` on the client method. These are +the closed set accepted by the service, rather than arbitrary custom roles. +Meanings follow the [standard PDF structure elements](https://pdfa.org/wp-content/uploads/2019/06/TaggedPDFBestPracticeGuideSyntax.pdf). + +Accepted values: + +- `P`: Paragraph. +- `H`: Heading without an explicit level. +- `H1`: Level-one heading. +- `H2`: Level-two heading. +- `H3`: Level-three heading. +- `H4`: Level-four heading. +- `H5`: Level-five heading. +- `H6`: Level-six heading. +- `Lbl`: Label, such as a list marker. +- `Span`: Inline text segment. +- `Quote`: Inline quotation. +- `Note`: Explanatory note, such as a footnote. +- `Reference`: Reference to other content. +- `BibEntry`: Bibliographic entry. +- `Code`: Computer code. +- `Link`: Hypertext link. +- `Annot`: Annotation content. +- `Ruby`: Ruby annotation grouping base text and its reading aid. +- `RB`: Ruby base text. +- `RT`: Ruby annotation text. +- `RP`: Ruby enclosing punctuation. +- `Warichu`: Small inline annotation split across two lines. +- `WT`: Warichu annotation text. +- `WP`: Warichu enclosing punctuation. +- `Figure`: Graphical content. +- `Formula`: Mathematical expression. +- `Form`: Form widget grouping. +""" class PdfAddLineObject(TypedDict, total=False): From d9abf38c876e59970b51b91c858ee360086ff3f7 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 7 Oct 2026 17:43:56 -0500 Subject: [PATCH 10/10] tests: Verify PDF shape contracts through both transports Share single-field boundary and structure-type cases between payload, client, and live tests. Check exact wire serialization and require local validation failures to stop before transport execution. Exercise invalid values through live body overrides and use the 20-page fixture to validate page selectors above the lower bound. Assisted-by: Codex --- tests/add_shapes_test_helpers.py | 164 ++++++++++++++++++++++ tests/live/test_live_add_shapes_to_pdf.py | 103 +++++++++++++- tests/test_add_shapes_to_pdf.py | 130 ++++++++++++++++- 3 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 tests/add_shapes_test_helpers.py diff --git a/tests/add_shapes_test_helpers.py b/tests/add_shapes_test_helpers.py new file mode 100644 index 0000000..c55fece --- /dev/null +++ b/tests/add_shapes_test_helpers.py @@ -0,0 +1,164 @@ +"""Shared, single-field contract cases for mocked and live shape tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import get_args + +import pytest + +from pdfrest.types import PdfContentStructureType + + +def make_shape(shape_type: str, **overrides: object) -> dict[str, object]: + if shape_type == "line": + shape: dict[str, object] = { + "type": "line", + "page": 1, + "x1": 72, + "y1": 576, + "x2": 540, + "y2": 576, + "stroke_color": (26, 72, 112), + "stroke_width": 1.5, + } + else: + shape = { + "type": "rectangle", + "page": 1, + "x": 54, + "y": 540, + "width": 504, + "height": 108, + "fill_color": (0, 0, 0, 12), + "opacity": 0.75, + } + shape.update(overrides) + return shape + + +def server_shape(shape: dict[str, object]) -> dict[str, object]: + """Independent expected wire object; do not derive it from the SDK model.""" + wire = dict(shape) + for field in ("stroke_color", "fill_color"): + color = wire.pop(field, None) + if isinstance(color, tuple): + color_space = "rgb" if len(color) == 3 else "cmyk" + wire[f"{field}_{color_space}"] = ",".join(str(channel) for channel in color) + return wire + + +@dataclass(frozen=True) +class ShapeBoundaryCase: + shape: dict[str, object] + field: str + constraint: str = "" + + @property + def local_match(self) -> str: + return rf"(?s){self.field}.*{self.constraint}" + + @property + def server_match(self) -> str: + # Legacy server messages call both fill/stroke channels color_rgb/cmyk. + field = "color" if self.field.endswith("color") else self.field + return rf"(?i){field}" + + +def _numeric_cases(valid: bool) -> list[object]: + cases = [] + for shape_type, coordinates, dimensions in ( + ("line", ("x1", "y1", "x2", "y2"), ()), + ("rectangle", ("x", "y"), ("width", "height")), + ): + fields = { + **dict.fromkeys( + coordinates, ((0, 0.01), (-0.01,), "greater than or equal to 0") + ), + **dict.fromkeys( + (*dimensions, "stroke_width"), ((0.01,), (0, -0.01), "greater than 0") + ), + "page": ((1, 2, "all"), (0,), "greater than or equal to 1"), + "opacity": ((0, 0.01, 0.99, 1), (-0.01, 1.01), ""), + } + for field, (accepted, rejected, constraint) in fields.items(): + for value in accepted if valid else rejected: + bound = constraint + if field == "opacity": + assert isinstance(value, (int, float)) + bound = ( + "greater than or equal to 0" + if value < 0 + else "less than or equal to 1" + ) + cases.append( + pytest.param( + ShapeBoundaryCase( + make_shape(shape_type, **{field: value}), field, bound + ), + id=f"{shape_type}-{field}-{value}", + ) + ) + for field in ( + ("stroke_color",) + if shape_type == "line" + else ("stroke_color", "fill_color") + ): + for count, maximum in ((3, 255), (4, 100)): + for channel in range(count): + for value in ( + (0, 1, maximum - 1, maximum) if valid else (-1, maximum + 1) + ): + color = tuple( + value if index == channel else 0 for index in range(count) + ) + constraint = ( + "greater than or equal to 0" + if value < 0 + else f"less than or equal to {maximum}" + ) + cases.append( + pytest.param( + ShapeBoundaryCase( + make_shape(shape_type, **{field: color}), + field, + constraint, + ), + id=f"{shape_type}-{field}-{count}-channel-{channel}-{value}", + ) + ) + return cases + + +VALID_BOUNDARY_CASES = _numeric_cases(valid=True) +INVALID_BOUNDARY_CASES = _numeric_cases(valid=False) + +VALID_SHAPE_CASES = [ + *VALID_BOUNDARY_CASES, + *[ + pytest.param( + ShapeBoundaryCase( + make_shape(shape_type, tag_structure_type=value), "tag_structure_type" + ), + id=f"{shape_type}-structure-{value}", + ) + for shape_type in ("line", "rectangle") + for value in get_args(PdfContentStructureType) + ], +] +INVALID_SHAPE_CASES = [ + *INVALID_BOUNDARY_CASES, + *[ + pytest.param( + ShapeBoundaryCase( + make_shape( + shape_type, tag_structure_type="DefinitelyNotAStructureType" + ), + "tag_structure_type", + "Input should be", + ), + id=f"{shape_type}-invalid-structure-type", + ) + for shape_type in ("line", "rectangle") + ], +] diff --git a/tests/live/test_live_add_shapes_to_pdf.py b/tests/live/test_live_add_shapes_to_pdf.py index 723f03d..985619c 100644 --- a/tests/live/test_live_add_shapes_to_pdf.py +++ b/tests/live/test_live_add_shapes_to_pdf.py @@ -5,6 +5,12 @@ from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile +from ..add_shapes_test_helpers import ( + INVALID_SHAPE_CASES, + VALID_SHAPE_CASES, + ShapeBoundaryCase, + server_shape, +) from ..resources import get_test_resource_path @@ -13,11 +19,14 @@ def uploaded_pdf_for_shape_addition( pdfrest_api_key: str, pdfrest_live_base_url: str, ) -> PdfRestFile: + """Use multiple pages so the first page above the lower bound is valid.""" with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - return client.files.create_from_paths([get_test_resource_path("report.pdf")])[0] + return client.files.create_from_paths([get_test_resource_path("20-pages.pdf")])[ + 0 + ] def _line() -> dict[str, object]: @@ -161,3 +170,95 @@ async def test_live_async_add_shapes_to_pdf_invalid_page( ] }, ) + + +@pytest.mark.parametrize("case", VALID_SHAPE_CASES) +def test_live_add_shapes_to_pdf_contract( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, + case: ShapeBoundaryCase, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects=case.shape, + tag_enabled="tag_structure_type" in case.shape, + output="live-shape-contract", + ) + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-shape-contract") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert uploaded_pdf_for_shape_addition.id in response.input_ids + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", VALID_SHAPE_CASES) +async def test_live_async_add_shapes_to_pdf_contract( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, + case: ShapeBoundaryCase, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = await client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects=case.shape, + tag_enabled="tag_structure_type" in case.shape, + output="live-shape-contract", + ) + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-shape-contract") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert uploaded_pdf_for_shape_addition.id in response.input_ids + + +@pytest.mark.parametrize("case", INVALID_SHAPE_CASES) +def test_live_add_shapes_to_pdf_invalid_contract( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, + case: ShapeBoundaryCase, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client, + pytest.raises(PdfRestApiError, match=case.server_match), + ): + client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects=_line(), + tag_enabled=True, + extra_body={"shape_objects": [server_shape(case.shape)]}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", INVALID_SHAPE_CASES) +async def test_live_async_add_shapes_to_pdf_invalid_contract( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_shape_addition: PdfRestFile, + case: ShapeBoundaryCase, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + with pytest.raises(PdfRestApiError, match=case.server_match): + await client.add_shapes_to_pdf( + uploaded_pdf_for_shape_addition, + shape_objects=_line(), + tag_enabled=True, + extra_body={"shape_objects": [server_shape(case.shape)]}, + ) diff --git a/tests/test_add_shapes_to_pdf.py b/tests/test_add_shapes_to_pdf.py index 370deb9..63c2cbc 100644 --- a/tests/test_add_shapes_to_pdf.py +++ b/tests/test_add_shapes_to_pdf.py @@ -8,9 +8,15 @@ from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import PdfAddShapesPayload +from .add_shapes_test_helpers import ( + INVALID_SHAPE_CASES, + VALID_SHAPE_CASES, + ShapeBoundaryCase, + server_shape, +) from .graphics_test_helpers import ( ASYNC_API_KEY, VALID_API_KEY, @@ -547,3 +553,125 @@ def test_add_shapes_payload_accepts_boundary_values(shape: dict[str, object]) -> ) assert payload.shape_objects[0].model_dump(mode="json", exclude_none=True) + + +def shape_contract_transport( + shape: dict[str, object], +) -> tuple[httpx.MockTransport, PdfRestFile]: + """Assert the request independently of the payload model being exercised.""" + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + expected_body = { + "id": str(pdf_file.id), + "shape_objects": [server_shape(shape)], + "tag_enabled": "tag_structure_type" in shape, + } + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-shapes": + assert json.loads(request.content) == expected_body + return httpx.Response( + 200, json={"inputId": [pdf_file.id], "outputId": [output_id]} + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "shapes.pdf", "application/pdf"), + ) + + return httpx.MockTransport(handler), pdf_file + + +@pytest.mark.parametrize("case", VALID_SHAPE_CASES) +def test_add_shapes_payload_contract(case: ShapeBoundaryCase) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + body = PdfAddShapesPayload.model_validate( + { + "files": pdf_file, + "shape_objects": case.shape, + "tag_enabled": "tag_structure_type" in case.shape, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + assert body == { + "id": str(pdf_file.id), + "shape_objects": [server_shape(case.shape)], + "tag_enabled": "tag_structure_type" in case.shape, + } + + +@pytest.mark.parametrize("case", VALID_SHAPE_CASES) +def test_add_shapes_to_pdf_contract(case: ShapeBoundaryCase) -> None: + transport, pdf_file = shape_contract_transport(case.shape) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_shapes_to_pdf( + pdf_file, + shape_objects=case.shape, + tag_enabled="tag_structure_type" in case.shape, + ) + assert response.input_id == pdf_file.id + assert response.output_file.name == "shapes.pdf" + assert response.output_file.type == "application/pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", VALID_SHAPE_CASES) +async def test_async_add_shapes_to_pdf_contract(case: ShapeBoundaryCase) -> None: + transport, pdf_file = shape_contract_transport(case.shape) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_shapes_to_pdf( + pdf_file, + shape_objects=case.shape, + tag_enabled="tag_structure_type" in case.shape, + ) + assert response.input_id == pdf_file.id + assert response.output_file.name == "shapes.pdf" + assert response.output_file.type == "application/pdf" + + +@pytest.mark.parametrize("case", INVALID_SHAPE_CASES) +def test_add_shapes_payload_invalid_contract(case: ShapeBoundaryCase) -> None: + with pytest.raises(ValidationError, match=case.local_match): + PdfAddShapesPayload.model_validate( + { + "files": make_pdf_file(PdfRestFileID.generate(1)), + "shape_objects": case.shape, + "tag_enabled": True, + } + ) + + +def unexpected_shape_request(_: httpx.Request) -> httpx.Response: + pytest.fail("Invalid shape must be rejected before transport execution.") + + +@pytest.mark.parametrize("case", INVALID_SHAPE_CASES) +def test_add_shapes_to_pdf_invalid_contract(case: ShapeBoundaryCase) -> None: + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(unexpected_shape_request), + ) as client, + pytest.raises(ValidationError, match=case.local_match), + ): + client.add_shapes_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + shape_objects=case.shape, + tag_enabled=True, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", INVALID_SHAPE_CASES) +async def test_async_add_shapes_to_pdf_invalid_contract( + case: ShapeBoundaryCase, +) -> None: + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(unexpected_shape_request) + ) as client: + with pytest.raises(ValidationError, match=case.local_match): + await client.add_shapes_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + shape_objects=case.shape, + tag_enabled=True, + )