diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98d8c17d..d07e947b 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/docs/api-guide.md b/docs/api-guide.md index 838170f5..aacfafad 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/examples/README.md b/examples/README.md index 76571230..b3089918 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 00000000..f527ea94 --- /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() diff --git a/pyproject.toml b/pyproject.toml index c744ad1a..2bdc5ded 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/src/pdfrest/client.py b/src/pdfrest/client.py index 7c66e190..dd1cc72b 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 50d434e4..8a72654e 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -29,6 +29,7 @@ HtmlWebLayout, OcrLanguage, PdfAType, + PdfContentStructureType, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -135,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]: @@ -148,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 @@ -240,6 +242,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 +1834,174 @@ 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( + 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( + 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[ + 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 + + +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( + 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( + 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 + + +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.""" @@ -1963,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, ) @@ -1978,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 f662b0b4..64cdb0e7 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -16,10 +16,15 @@ HtmlWebLayout, JpegColorModel, OcrLanguage, + PdfAddLineObject, + PdfAddRectangleObject, + PdfAddShapeObject, PdfAddTextObject, PdfAType, PdfCMYKColor, + PdfColor, PdfColorProfile, + PdfContentStructureType, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -70,9 +75,14 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddLineObject", + "PdfAddRectangleObject", + "PdfAddShapeObject", "PdfAddTextObject", "PdfCMYKColor", + "PdfColor", "PdfColorProfile", + "PdfContentStructureType", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 5b7689ea..09c065cb 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 @@ -29,9 +29,14 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddLineObject", + "PdfAddRectangleObject", + "PdfAddShapeObject", "PdfAddTextObject", "PdfCMYKColor", + "PdfColor", "PdfColorProfile", + "PdfContentStructureType", "PdfConversionCompression", "PdfConversionDownsample", "PdfConversionLocale", @@ -135,7 +140,123 @@ 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", + "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 [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] + x1: Required[float] + y1: Required[float] + x2: Required[float] + y2: Required[float] + stroke_color: PdfColor + 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 [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] + x: Required[float] + y: Required[float] + width: Required[float] + height: Required[float] + fill_color: PdfColor + stroke_color: PdfColor + stroke_width: float + opacity: float + tag_actual_text: str + tag_is_artifact: bool + tag_structure_type: PdfContentStructureType + + +PdfAddShapeObject: TypeAlias = PdfAddLineObject | PdfAddRectangleObject +"""A line or rectangle object accepted by +[pdfrest.PdfRestClient.add_shapes_to_pdf][].""" class PdfAddTextObject(TypedDict, total=False): diff --git a/tests/conftest.py b/tests/conftest.py index e4c4e7a5..1be9c0c7 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) 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 00000000..723f03d3 --- /dev/null +++ b/tests/live/test_live_add_shapes_to_pdf.py @@ -0,0 +1,163 @@ +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": (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": (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, + 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": [ + { + **_server_line(), + "page": 0, + } + ] + }, + ) + + +@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": [ + { + **_server_rectangle(), + "page": 0, + } + ] + }, + ) diff --git a/tests/test_add_shapes_to_pdf.py b/tests/test_add_shapes_to_pdf.py new file mode 100644 index 00000000..370deb99 --- /dev/null +++ b/tests/test_add_shapes_to_pdf.py @@ -0,0 +1,549 @@ +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": (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": (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=(245, 247, 250), + 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()) + 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]} + ) + 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" + 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( + ("shape_objects", "tag_enabled", "match"), + [ + pytest.param([], None, "at least 1 item", id="empty-shapes"), + pytest.param( + make_line(stroke_color=(0, 0)), + None, + re.escape("stroke_color must include exactly 3 (RGB) or 4 (CMYK) values."), + id="line-color-channel-count", + ), + pytest.param( + make_rectangle(fill_color=(0, 0)), + None, + re.escape("fill_color must include exactly 3 (RGB) or 4 (CMYK) values."), + id="rectangle-color-channel-count", + ), + 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(), + ) + + +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=(-1, 0, 0)), + "greater than or equal to 0", + id="rgb-below", + ), + pytest.param( + 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=(-1, 0, 0, 0)), + "greater than or equal to 0", + id="cmyk-below", + ), + pytest.param( + make_rectangle(fill_color=(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=(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=(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) diff --git a/uv.lock b/uv.lock index 78e59879..a48fd046 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" },