diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index ce8af9c9..da6dd841 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -82,6 +82,9 @@ GifPdfRestPayload, JpegPdfRestPayload, OcrPdfPayload, + PdfAddAttachmentPayload, + PdfAddImagePayload, + PdfAddTextPayload, PdfCompressPayload, PdfDecryptPayload, PdfEncryptPayload, @@ -120,6 +123,7 @@ GraphicSmoothing, JpegColorModel, OcrLanguage, + PdfAddTextObject, PdfAType, PdfInfoQuery, PdfMergeInput, @@ -2547,6 +2551,72 @@ def apply_redactions( timeout=timeout, ) + def add_text_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + text_objects: PdfAddTextObject | Sequence[PdfAddTextObject], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Insert one or more text blocks into a PDF.""" + + payload: dict[str, Any] = { + "files": file, + "text_objects": text_objects, + } + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-added-text", + payload=payload, + payload_model=PdfAddTextPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + + def add_image_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + image: PdfRestFile | Sequence[PdfRestFile], + x: int, + y: int, + page: int, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Insert an image into a single page of a PDF.""" + + payload: dict[str, Any] = { + "files": file, + "image": image, + "x": x, + "y": y, + "page": page, + } + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-added-image", + payload=payload, + payload_model=PdfAddImagePayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def split_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -2973,6 +3043,33 @@ def compress_pdf( timeout=timeout, ) + def add_attachment_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + attachment: PdfRestFile | Sequence[PdfRestFile], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Attach an uploaded file to a PDF.""" + + payload: dict[str, Any] = {"files": file, "attachment": attachment} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-added-attachment", + payload=payload, + payload_model=PdfAddAttachmentPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -3792,6 +3889,72 @@ async def apply_redactions( timeout=timeout, ) + async def add_text_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + text_objects: PdfAddTextObject | Sequence[PdfAddTextObject], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously insert text blocks into a PDF.""" + + payload: dict[str, Any] = { + "files": file, + "text_objects": text_objects, + } + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-added-text", + payload=payload, + payload_model=PdfAddTextPayload, + 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], + *, + image: PdfRestFile | Sequence[PdfRestFile], + x: int, + y: int, + page: int, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously insert an image into a PDF.""" + + payload: dict[str, Any] = { + "files": file, + "image": image, + "x": x, + "y": y, + "page": page, + } + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-added-image", + payload=payload, + payload_model=PdfAddImagePayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def up( self, *, @@ -4260,6 +4423,33 @@ async def compress_pdf( timeout=timeout, ) + async def add_attachment_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + attachment: PdfRestFile | Sequence[PdfRestFile], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Asynchronously attach an uploaded file to a PDF.""" + + payload: dict[str, Any] = {"files": file, "attachment": attachment} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-added-attachment", + payload=payload, + payload_model=PdfAddAttachmentPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 121e7eab..997f3937 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import re from collections.abc import Callable, Sequence from pathlib import PurePath @@ -19,6 +18,7 @@ model_serializer, model_validator, ) +from pydantic_core import to_json from pdfrest.types.public import PdfRedactionPreset @@ -144,8 +144,30 @@ def _serialize_grouped_page_ranges( def _serialize_redactions(value: list[_PdfRedactionVariant]) -> str: - payload = [entry.model_dump(mode="json", exclude_none=True) for entry in value] - return json.dumps(payload, separators=(",", ":")) + return ( + "[" + + ",".join(entry.model_dump_json(exclude_none=True) for entry in value) + + "]" + ) + + +def _serialize_text_object_value(value: Any) -> Any: + if isinstance(value, str): + return value + return to_json(value).decode() + + +def _serialize_text_objects(value: list[BaseModel]) -> str: + payload = [ + { + key: _serialize_text_object_value(field_value) + for key, field_value in entry.model_dump( + mode="json", exclude_none=True + ).items() + } + for entry in value + ] + return to_json(payload).decode() def _allowed_mime_types( @@ -563,6 +585,7 @@ class ExtractImagesPayload(BaseModel): RgbChannel = Annotated[int, Field(ge=0, le=255)] +CmykChannel = Annotated[int, Field(ge=0, le=100)] class PdfLiteralRedactionModel(BaseModel): @@ -1021,6 +1044,142 @@ def _validate_profile_dependency(self) -> PdfCompressPayload: return self +class PdfAddTextObjectModel(BaseModel): + """Adapt caller text options for insertion into a PDF.""" + + font: Annotated[str, Field(min_length=1, serialization_alias="font")] + max_width: Annotated[ + float, + Field(serialization_alias="max_width", gt=0), + ] + opacity: Annotated[ + float, + Field(serialization_alias="opacity", ge=0.0, le=1.0), + ] + page: Annotated[ + Literal["all"] | Annotated[int, Field(ge=1)], + Field(serialization_alias="page"), + ] + rotation: Annotated[float, Field(serialization_alias="rotation")] + text: Annotated[str, Field(min_length=1, serialization_alias="text")] + text_color_rgb: Annotated[ + tuple[RgbChannel, RgbChannel, RgbChannel] | None, + Field(serialization_alias="text_color_rgb", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + text_color_cmyk: Annotated[ + tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None, + Field(serialization_alias="text_color_cmyk", default=None), + BeforeValidator(_split_comma_string), + PlainSerializer(_serialize_as_comma_separated_string), + ] = None + text_size: Annotated[ + float, + Field(serialization_alias="text_size", ge=5, le=100), + ] + x: Annotated[float, Field(serialization_alias="x")] + y: Annotated[float, Field(serialization_alias="y")] + is_rtl: Annotated[ + bool | None, + Field( + validation_alias=AliasChoices("is_right_to_left", "is_rtl"), + serialization_alias="is_rtl", + default=None, + ), + ] = None + + @model_validator(mode="after") + def _ensure_single_color_option(self) -> PdfAddTextObjectModel: + if self.text_color_rgb is None and self.text_color_cmyk is None: + msg = "Either text_color_rgb or text_color_cmyk must be provided." + raise ValueError(msg) + if self.text_color_rgb is not None and self.text_color_cmyk is not None: + msg = "Provide only one of text_color_rgb or text_color_cmyk." + raise ValueError(msg) + return self + + +class PdfAddTextPayload(BaseModel): + """Adapt caller options into a pdfRest-ready add-text 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), + ] + text_objects: Annotated[ + list[PdfAddTextObjectModel], + Field( + serialization_alias="text_objects", + min_length=1, + ), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_text_objects), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + +class PdfAddImagePayload(BaseModel): + """Adapt caller options into a pdfRest-ready add-image 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), + ] + image: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("image", "images", "image_file", "image_id"), + serialization_alias="image_id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types( + "image/jpeg", + "image/png", + "image/tiff", + "image/gif", + error_msg="Image must be JPEG, PNG, TIFF, or GIF", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] + x: Annotated[int, Field(serialization_alias="x")] + y: Annotated[int, Field(serialization_alias="y")] + page: Annotated[int, Field(serialization_alias="page", ge=1)] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class PdfXfaToAcroformsPayload(BaseModel): """Adapt caller options into a pdfRest-ready XFA-to-AcroForms request payload.""" @@ -1142,6 +1301,46 @@ class PdfFlattenAnnotationsPayload(BaseModel): ] = None +class PdfAddAttachmentPayload(BaseModel): + """Adapt caller options into a pdfRest-ready add-attachment 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), + ] + attachments: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices( + "attachment", + "attachments", + "file_to_attach", + "files_to_attach", + ), + serialization_alias="id_to_attach", + ), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_as_first_file_id), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class PdfRestrictPayload(BaseModel): """Adapt caller options into a pdfRest-ready restrict-PDF request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 0d139641..f305cd9e 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -12,7 +12,9 @@ GraphicSmoothing, JpegColorModel, OcrLanguage, + PdfAddTextObject, PdfAType, + PdfCmykColor, PdfInfoQuery, PdfMergeInput, PdfMergeSource, @@ -44,6 +46,8 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddTextObject", + "PdfCmykColor", "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 020f012f..27821ec4 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -25,6 +25,8 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddTextObject", + "PdfCmykColor", "PdfInfoQuery", "PdfMergeInput", "PdfMergeSource", @@ -106,6 +108,24 @@ class PdfRedactionInstruction(TypedDict): PdfRGBColor = tuple[int, int, int] +PdfCmykColor = tuple[int, int, int, int] + + +class PdfAddTextObject(TypedDict, total=False): + font: Required[str] + max_width: Required[float] + opacity: Required[float] + page: Required[Literal["all"] | int] + rotation: Required[float] + text: Required[str] + text_color_rgb: PdfRGBColor + text_color_cmyk: PdfCmykColor + text_size: Required[float] + x: Required[float] + y: Required[float] + is_right_to_left: bool + + PdfPageSelection = str | int | Sequence[str | int] diff --git a/tests/graphics_test_helpers.py b/tests/graphics_test_helpers.py index b94d5fc3..c09ffe41 100644 --- a/tests/graphics_test_helpers.py +++ b/tests/graphics_test_helpers.py @@ -40,6 +40,26 @@ def make_pdf_file(file_id: str, name: str = "example.pdf") -> PdfRestFile: ) +def make_image_file( + file_id: str, + mime_type: str = "image/png", + name: str = "example.png", +) -> PdfRestFile: + return PdfRestFile.model_validate( + { + "id": file_id, + "name": name, + "url": f"https://api.pdfrest.com/resource/{file_id}", + "type": mime_type, + "size": 2048, + "modified": datetime(2024, 1, 1, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "scheduledDeletionTimeUtc": None, + } + ) + + def assert_conversion_payload( payload: dict[str, Any], expected: dict[str, Any], diff --git a/tests/live/test_live_add_attachment_to_pdf.py b/tests/live/test_live_add_attachment_to_pdf.py new file mode 100644 index 00000000..cf5b1aff --- /dev/null +++ b/tests/live/test_live_add_attachment_to_pdf.py @@ -0,0 +1,130 @@ +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_attachment( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_attachment_file( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.docx") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +def test_live_add_attachment_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_attachment: PdfRestFile, + uploaded_attachment_file: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.add_attachment_to_pdf( + uploaded_pdf_for_attachment, + attachment=uploaded_attachment_file, + output="with-attachment", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("with-attachment") + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + assert [str(file_id) for file_id in response.input_ids] == [ + str(uploaded_pdf_for_attachment.id), + str(uploaded_attachment_file.id), + ] + + +@pytest.mark.asyncio +async def test_live_async_add_attachment_to_pdf_success( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_attachment: PdfRestFile, + uploaded_attachment_file: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.add_attachment_to_pdf( + uploaded_pdf_for_attachment, + attachment=uploaded_attachment_file, + output="async-attachment", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-attachment") + assert output_file.type == "application/pdf" + assert output_file.size > 0 + assert response.warning is None + assert [str(file_id) for file_id in response.input_ids] == [ + str(uploaded_pdf_for_attachment.id), + str(uploaded_attachment_file.id), + ] + + +def test_live_add_attachment_to_pdf_invalid_file_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_attachment: PdfRestFile, + uploaded_attachment_file: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)(id|file)"), + ): + client.add_attachment_to_pdf( + uploaded_pdf_for_attachment, + attachment=uploaded_attachment_file, + extra_body={"id": "00000000-0000-0000-0000-000000000000"}, + ) + + +@pytest.mark.asyncio +async def test_live_async_add_attachment_to_pdf_invalid_attachment_id( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_attachment: PdfRestFile, + uploaded_attachment_file: 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)(id|file)"): + await client.add_attachment_to_pdf( + uploaded_pdf_for_attachment, + attachment=uploaded_attachment_file, + extra_body={"id_to_attach": "ffffffff-ffff-ffff-ffff-ffffffffffff"}, + ) diff --git a/tests/live/test_live_add_image_to_pdf.py b/tests/live/test_live_add_image_to_pdf.py new file mode 100644 index 00000000..98889709 --- /dev/null +++ b/tests/live/test_live_add_image_to_pdf.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + +IMAGE_RESOURCE_VARIANTS = ( + pytest.param(("test.jpg", "image/jpeg"), id="jpeg"), + pytest.param(("test.png", "image/png"), id="png"), + pytest.param(("test.tif", "image/tiff"), id="tiff"), + pytest.param(("test.gif", "image/gif"), id="gif"), +) + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_image_addition( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module", params=IMAGE_RESOURCE_VARIANTS) +def uploaded_image_variant( + request: pytest.FixtureRequest, + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource_name, expected_mime = request.param + resource = get_test_resource_path(resource_name) + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded_image = client.files.create_from_paths([resource])[0] + + assert uploaded_image.type == expected_mime + return uploaded_image + + +def test_live_add_image_to_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_image_variant: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.add_image_to_pdf( + uploaded_pdf_for_image_addition, + image=uploaded_image_variant, + x=25, + y=50, + page=1, + output="live-added-image", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.name.startswith("live-added-image") + assert output_file.size > 0 + assert response.warning is None + assert uploaded_pdf_for_image_addition.id in response.input_ids + assert uploaded_image_variant.id in response.input_ids + + +@pytest.mark.asyncio +async def test_live_async_add_image_to_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_image_variant: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.add_image_to_pdf( + uploaded_pdf_for_image_addition, + image=uploaded_image_variant, + x=75, + y=125, + page=1, + ) + + 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_image_addition.id in response.input_ids + assert uploaded_image_variant.id in response.input_ids + + +def test_live_add_image_to_pdf_invalid_page( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_image_variant: 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_image_to_pdf( + uploaded_pdf_for_image_addition, + image=uploaded_image_variant, + x=0, + y=0, + page=1, + extra_body={"page": 0}, + ) + + +@pytest.mark.asyncio +async def test_live_async_add_image_to_pdf_invalid_page( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_image_variant: 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_image_to_pdf( + uploaded_pdf_for_image_addition, + image=uploaded_image_variant, + x=0, + y=0, + page=1, + extra_body={"page": 0}, + ) diff --git a/tests/live/test_live_add_text_to_pdf.py b/tests/live/test_live_add_text_to_pdf.py new file mode 100644 index 00000000..52f2b479 --- /dev/null +++ b/tests/live/test_live_add_text_to_pdf.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import pytest +from pydantic_core import to_json + +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_text( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +def _default_text_object() -> dict[str, object]: + return { + "font": "courier", + "max_width": 200, + "opacity": 1, + "page": 1, + "rotation": 0, + "text": "Live add text", + "text_color_rgb": (0, 0, 0), + "text_size": 14, + "x": 72, + "y": 144, + } + + +def _serialize_text_object_for_extra_body( + text_object: dict[str, object], +) -> dict[str, object]: + serialized = dict(text_object) + # PdfAddTextObjectModel coercion: numeric placement/size fields are floats on + # serialization even when callers provide integers. + for key in ("max_width", "rotation", "text_size", "x", "y"): + value = serialized.get(key) + if isinstance(value, int): + serialized[key] = float(value) + rgb = serialized.get("text_color_rgb") + if isinstance(rgb, (list, tuple)): + serialized["text_color_rgb"] = ",".join(str(channel) for channel in rgb) + cmyk = serialized.get("text_color_cmyk") + if isinstance(cmyk, (list, tuple)): + serialized["text_color_cmyk"] = ",".join(str(channel) for channel in cmyk) + # Match add-text wire format where each non-string value is JSON-quoted. + for key, value in list(serialized.items()): + if not isinstance(value, str): + serialized[key] = to_json(value).decode() + return serialized + + +def test_live_add_text_to_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_text: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.add_text_to_pdf( + uploaded_pdf_for_text, + text_objects=[_default_text_object()], + output="live-added-text", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.type == "application/pdf" + assert output_file.name.startswith("live-added-text") + assert output_file.size > 0 + assert response.warning is None + assert uploaded_pdf_for_text.id in response.input_ids + + +@pytest.mark.asyncio +async def test_live_async_add_text_to_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_text: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.add_text_to_pdf( + uploaded_pdf_for_text, + text_objects=[_default_text_object()], + ) + + 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_text.id in response.input_ids + + +def test_live_add_text_to_pdf_invalid_page( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_text: 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_text_to_pdf( + uploaded_pdf_for_text, + text_objects=[_default_text_object()], + extra_body={ + "text_objects": to_json( + [ + _serialize_text_object_for_extra_body( + { + **_default_text_object(), + "page": 0, + } + ) + ] + ).decode() + }, + ) + + +@pytest.mark.asyncio +async def test_live_async_add_text_to_pdf_invalid_page( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_text: 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_text_to_pdf( + uploaded_pdf_for_text, + text_objects=[_default_text_object()], + extra_body={ + "text_objects": to_json( + [ + _serialize_text_object_for_extra_body( + { + **_default_text_object(), + "page": 0, + } + ) + ] + ).decode() + }, + ) diff --git a/tests/resources/test.gif b/tests/resources/test.gif new file mode 100644 index 00000000..f880561f Binary files /dev/null and b/tests/resources/test.gif differ diff --git a/tests/resources/test.jpg b/tests/resources/test.jpg new file mode 100644 index 00000000..964703c9 Binary files /dev/null and b/tests/resources/test.jpg differ diff --git a/tests/resources/test.png b/tests/resources/test.png new file mode 100644 index 00000000..5e40cc7b Binary files /dev/null and b/tests/resources/test.png differ diff --git a/tests/resources/test.tif b/tests/resources/test.tif new file mode 100644 index 00000000..154c2f95 Binary files /dev/null and b/tests/resources/test.tif differ diff --git a/tests/test_add_attachment_to_pdf.py b/tests/test_add_attachment_to_pdf.py new file mode 100644 index 00000000..237d2a6b --- /dev/null +++ b/tests/test_add_attachment_to_pdf.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def make_attachment_file( + file_id: str, + name: str = "attachment.txt", + mime_type: str = "text/plain", +) -> PdfRestFile: + return PdfRestFile.model_validate(build_file_info_payload(file_id, name, mime_type)) + + +def test_add_attachment_to_pdf_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + attachment = make_attachment_file(str(PdfRestFileID.generate()), "notes.txt") + 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-attachment" + ): + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(input_file.id) + assert payload["id_to_attach"] == str(attachment.id) + assert payload["output"] == "attached" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id, attachment.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "attached.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_attachment_to_pdf( + input_file, + attachment=attachment, + output="attached", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "attached.pdf" + assert response.output_file.type == "application/pdf" + assert [str(file_id) for file_id in response.input_ids] == [ + str(input_file.id), + str(attachment.id), + ] + assert response.warning is None + + +@pytest.mark.asyncio +async def test_async_add_attachment_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + attachment = make_attachment_file( + str(PdfRestFileID.generate()), + "doc.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if ( + request.method == "POST" + and request.url.path == "/pdf-with-added-attachment" + ): + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(input_file.id) + assert payload["id_to_attach"] == str(attachment.id) + assert payload["output"] == "async-attachment" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id, attachment.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-attachment.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_attachment_to_pdf( + input_file, + attachment=attachment, + output="async-attachment", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-attachment.pdf" + assert [str(file_id) for file_id in response.input_ids] == [ + str(input_file.id), + str(attachment.id), + ] + + +def test_add_attachment_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + attachment = make_attachment_file( + str(PdfRestFileID.generate()), + "image.png", + "image/png", + ) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if ( + request.method == "POST" + and request.url.path == "/pdf-with-added-attachment" + ): + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(input_file.id) + assert payload["id_to_attach"] == str(attachment.id) + assert payload["output"] == "custom-output" + assert payload["diagnostics"] == "on" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id, attachment.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "sync" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "custom-output.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_attachment_to_pdf( + input_file, + attachment=attachment, + output="custom-output", + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "sync"}, + extra_body={"diagnostics": "on"}, + timeout=0.5, + ) + + assert response.output_file.name == "custom-output.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(0.5) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(0.5) + + +@pytest.mark.asyncio +async def test_async_add_attachment_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + attachment = make_attachment_file( + str(PdfRestFileID.generate()), + "data.json", + "application/json", + ) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if ( + request.method == "POST" + and request.url.path == "/pdf-with-added-attachment" + ): + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(input_file.id) + assert payload["id_to_attach"] == str(attachment.id) + assert payload["output"] == "async-output" + assert payload["notify"] == "yes" + return httpx.Response( + 200, + json={ + "inputId": [input_file.id, attachment.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-output.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_attachment_to_pdf( + input_file, + attachment=attachment, + output="async-output", + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async"}, + extra_body={"notify": "yes"}, + timeout=1.25, + ) + + assert response.output_file.name == "async-output.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(1.25) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(1.25) + + +def test_add_attachment_to_pdf_requires_pdf_file( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + not_pdf = make_attachment_file( + str(PdfRestFileID.generate()), + "image.png", + "image/png", + ) + attachment = make_attachment_file(str(PdfRestFileID.generate()), "note.txt") + transport = httpx.MockTransport( + lambda request: (_ for _ in ()).throw(RuntimeError("should not send")) + ) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.add_attachment_to_pdf(not_pdf, attachment=attachment) + + +def test_add_attachment_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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at most 1 item"), + ): + client.add_attachment_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + attachment=make_attachment_file(str(PdfRestFileID.generate())), + ) + + +def test_add_attachment_to_pdf_rejects_multiple_attachments( + 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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at most 1 item"), + ): + client.add_attachment_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + attachment=[ + make_attachment_file(str(PdfRestFileID.generate(2))), + make_attachment_file(str(PdfRestFileID.generate(2)), "more.txt"), + ], + ) + + +@pytest.mark.asyncio +async def test_async_add_attachment_to_pdf_requires_pdf_file( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + not_pdf = make_attachment_file( + str(PdfRestFileID.generate()), + "image.png", + "image/png", + ) + attachment = make_attachment_file(str(PdfRestFileID.generate()), "note.txt") + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Request should not be sent when validation fails.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.add_attachment_to_pdf(not_pdf, attachment=attachment) + + +@pytest.mark.asyncio +async def test_async_add_attachment_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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="at most 1 item"): + await client.add_attachment_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + attachment=make_attachment_file(str(PdfRestFileID.generate())), + ) + + +@pytest.mark.asyncio +async def test_async_add_attachment_to_pdf_rejects_multiple_attachments( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="at most 1 item"): + await client.add_attachment_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + attachment=[ + make_attachment_file(str(PdfRestFileID.generate(2))), + make_attachment_file(str(PdfRestFileID.generate(2)), "more.txt"), + ], + ) diff --git a/tests/test_add_image_to_pdf.py b/tests/test_add_image_to_pdf.py new file mode 100644 index 00000000..a3f781a6 --- /dev/null +++ b/tests/test_add_image_to_pdf.py @@ -0,0 +1,516 @@ +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 .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_image_file, + make_pdf_file, +) + +IMAGE_MIME_VARIANTS = ( + pytest.param("image/jpeg", "logo.jpg", id="jpeg"), + pytest.param("image/png", "logo.png", id="png"), + pytest.param("image/tiff", "logo.tif", id="tiff"), + pytest.param("image/gif", "logo.gif", id="gif"), +) + + +@pytest.mark.parametrize(("image_mime_type", "image_name"), IMAGE_MIME_VARIANTS) +def test_add_image_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, + image_mime_type: str, + image_name: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file( + PdfRestFileID.generate(2), + mime_type=image_mime_type, + name=image_name, + ) + 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-image": + seen["post"] += 1 + assert request.headers["wsn"] == "pdfrest-python" + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(pdf_file.id) + assert payload["image_id"] == str(image_file.id) + assert payload["x"] == 12 + assert payload["y"] == 34 + assert payload["page"] == 3 + assert payload["output"] == "with-image" + return httpx.Response( + 200, + json={ + "inputId": [pdf_file.id, image_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "with-image.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_image_to_pdf( + pdf_file, + image=image_file, + x=12, + y=34, + page=3, + output="with-image", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert len(response.input_ids) == 2 + assert pdf_file.id in response.input_ids + assert image_file.id in response.input_ids + output_file = response.output_file + assert output_file.name == "with-image.pdf" + assert output_file.type == "application/pdf" + assert output_file.size == 256 + assert str(output_file.url).endswith(output_id) + assert response.warning is None + + +def test_add_image_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + output_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-image": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + payload = json.loads(request.content.decode("utf-8")) + assert payload["x"] == 100 + assert payload["y"] == 200 + assert payload["page"] == 1 + assert payload["id"] == str(pdf_file.id) + assert payload["image_id"] == str(image_file.id) + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json={ + "inputId": [pdf_file.id, image_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "custom-with-image.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_image_to_pdf( + pdf_file, + image=image_file, + x=100, + y=200, + page=1, + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "1"}, + extra_body={"page": 1}, + timeout=0.5, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files[0].name == "custom-with-image.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all( + component == pytest.approx(0.5) for component in timeout_value.values() + ) + else: + assert timeout_value == pytest.approx(0.5) + + +def test_add_image_to_pdf_pdf_mime_validation( + 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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.add_image_to_pdf( + make_image_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + x=1, + y=2, + page=1, + ) + + +def test_add_image_to_pdf_image_mime_validation( + 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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, match=re.escape("Image must be JPEG, PNG, TIFF, or GIF") + ), + ): + client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_pdf_file(PdfRestFileID.generate(2)), + x=1, + y=2, + page=1, + ) + + +def test_add_image_to_pdf_page_minimum(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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="greater than or equal to 1"), + ): + client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + x=0, + y=0, + page=0, + ) + + +def test_add_image_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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at most 1 item"), + ): + client.add_image_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + image=make_image_file(PdfRestFileID.generate(2)), + x=1, + y=1, + page=1, + ) + + +def test_add_image_to_pdf_rejects_multiple_images( + 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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at most 1 item"), + ): + client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=[ + make_image_file(PdfRestFileID.generate(2)), + make_image_file(PdfRestFileID.generate(2), name="secondary.png"), + ], + x=1, + y=1, + page=1, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("image_mime_type", "image_name"), IMAGE_MIME_VARIANTS) +async def test_async_add_image_to_pdf_success( + monkeypatch: pytest.MonkeyPatch, + image_mime_type: str, + image_name: str, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file( + PdfRestFileID.generate(2), + mime_type=image_mime_type, + name=image_name, + ) + 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-image": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(pdf_file.id) + assert payload["image_id"] == str(image_file.id) + assert payload["x"] == 5 + assert payload["y"] == 6 + assert payload["page"] == 7 + assert payload["output"] == "async-with-image" + return httpx.Response( + 200, + json={ + "inputId": [pdf_file.id, image_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-image.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_image_to_pdf( + pdf_file, + image=image_file, + x=5, + y=6, + page=7, + output="async-with-image", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == "async-with-image.pdf" + assert len(response.input_ids) == 2 + + +@pytest.mark.asyncio +async def test_async_add_image_to_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-image": + assert request.url.params["trace"] == "true" + assert request.headers["X-Test"] == "async" + payload = json.loads(request.content.decode("utf-8")) + assert payload["x"] == 15 + assert payload["y"] == 25 + captured_timeout["value"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json={ + "inputId": [pdf_file.id, image_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-with-image.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_image_to_pdf( + pdf_file, + image=image_file, + x=15, + y=25, + page=1, + extra_query={"trace": "true"}, + extra_headers={"X-Test": "async"}, + extra_body={"x": 15}, + timeout=1.0, + ) + + assert response.output_files[0].name == "async-custom-with-image.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.asyncio +async def test_async_add_image_to_pdf_invalid_image_type( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, match=re.escape("Image must be JPEG, PNG, TIFF, or GIF") + ): + await client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_pdf_file(PdfRestFileID.generate(2)), + x=1, + y=1, + page=1, + ) + + +@pytest.mark.asyncio +async def test_async_add_image_to_pdf_invalid_pdf_type( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.add_image_to_pdf( + make_image_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + x=1, + y=2, + page=1, + ) + + +@pytest.mark.asyncio +async def test_async_add_image_to_pdf_page_minimum( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="greater than or equal to 1"): + await client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + x=0, + y=0, + page=0, + ) + + +@pytest.mark.asyncio +async def test_async_add_image_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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="at most 1 item"): + await client.add_image_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + image=make_image_file(PdfRestFileID.generate(2)), + x=1, + y=1, + page=1, + ) + + +@pytest.mark.asyncio +async def test_async_add_image_to_pdf_rejects_multiple_images( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="at most 1 item"): + await client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=[ + make_image_file(PdfRestFileID.generate(2)), + make_image_file(PdfRestFileID.generate(2), name="secondary.png"), + ], + x=1, + y=1, + page=1, + ) diff --git a/tests/test_add_text_to_pdf.py b/tests/test_add_text_to_pdf.py new file mode 100644 index 00000000..9731354e --- /dev/null +++ b/tests/test_add_text_to_pdf.py @@ -0,0 +1,631 @@ +from __future__ import annotations + +import json +import re + +import httpx +import pytest +from pydantic import ValidationError +from pydantic_core import to_json + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_image_file, + make_pdf_file, +) + + +def make_text_object(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "font": "courier", + "max_width": 200, + "opacity": 0.75, + "page": 1, + "rotation": 0, + "text": "Hello, PDF!", + "text_color_rgb": (0, 0, 0), + "text_size": 12, + "x": 72, + "y": 144, + } + base.update(overrides) + return base + + +def _serialize_text_object_for_request( + text_object: dict[str, object], +) -> dict[str, object]: + serialized = dict(text_object) + # PdfAddTextObjectModel defines these fields as floats, so Pydantic serializes + # integer inputs as 200.0/45.0/etc. Mirror that to keep wire assertions exact. + for key in ("max_width", "rotation", "text_size", "x", "y"): + value = serialized.get(key) + if isinstance(value, int): + serialized[key] = float(value) + rgb = serialized.get("text_color_rgb") + if isinstance(rgb, (list, tuple)): + serialized["text_color_rgb"] = ",".join(str(channel) for channel in rgb) + cmyk = serialized.get("text_color_cmyk") + if isinstance(cmyk, (list, tuple)): + serialized["text_color_cmyk"] = ",".join(str(channel) for channel in cmyk) + if "is_right_to_left" in serialized: + serialized["is_rtl"] = serialized.pop("is_right_to_left") + # Add-text payloads now quote non-string values inside the text_objects JSON. + for key, value in list(serialized.items()): + if not isinstance(value, str): + serialized[key] = to_json(value).decode() + return serialized + + +def test_add_text_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-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(pdf_file.id) + assert payload["output"] == "with-text" + assert ( + payload["text_objects"] + == to_json( + [ + _serialize_text_object_for_request( + make_text_object(is_right_to_left=True) + ) + ] + ).decode() + ) + 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-text.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_text_to_pdf( + pdf_file, + text_objects=[make_text_object(is_right_to_left=True)], + output="with-text", + ) + + assert seen == {"post": 1, "get": 1} + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "with-text.pdf" + assert response.output_file.type == "application/pdf" + assert str(pdf_file.id) in [str(file_id) for file_id in response.input_ids] + assert response.warning is None + + +def test_add_text_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, float | dict[str, float] | None] = {} + overridden_text_object = make_text_object(page=2) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-text": + assert request.url.params["trace"] == "true" + assert request.headers["X-Debug"] == "1" + payload = json.loads(request.content.decode("utf-8")) + assert payload["rotation"] == 15 + assert ( + payload["text_objects"] + == to_json( + [_serialize_text_object_for_request(overridden_text_object)], + ).decode() + ) + 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, + "custom-text.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_text_to_pdf( + pdf_file, + text_objects=[make_text_object(rotation=15)], + extra_query={"trace": "true"}, + extra_headers={"X-Debug": "1"}, + extra_body={ + "rotation": 15, + "text_objects": to_json( + [_serialize_text_object_for_request(overridden_text_object)], + ).decode(), + }, + timeout=0.25, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_files[0].name == "custom-text.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) + + +def test_add_text_to_pdf_requires_color(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.") + + transport = httpx.MockTransport(handler) + text_object = make_text_object() + text_object.pop("text_color_rgb") + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape( + "Either text_color_rgb or text_color_cmyk must be provided." + ), + ), + ): + client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[text_object], + ) + + +def test_add_text_to_pdf_pdf_mime_validation(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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="Must be a PDF file"), + ): + client.add_text_to_pdf( + make_image_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object()], + ) + + +def test_add_text_to_pdf_page_minimum(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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="greater than or equal to 1"), + ): + client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(page=0)], + ) + + +def test_add_text_to_pdf_rejects_both_color_modes( + 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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=re.escape("Provide only one of text_color_rgb or text_color_cmyk."), + ), + ): + client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(text_color_cmyk=(0, 0, 0, 0))], + ) + + +def test_add_text_to_pdf_rgb_bounds(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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match="less than or equal to 255", + ), + ): + client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(text_color_rgb=(0, 0, 256))], + ) + + +def test_add_text_to_pdf_text_size_bounds( + 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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="greater than or equal to 5"), + ): + client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(text_size=4)], + ) + + +def test_add_text_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.") + + transport = httpx.MockTransport(handler) + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match="at most 1 item"), + ): + client.add_text_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + text_objects=[make_text_object()], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_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-text": + seen["post"] += 1 + payload = json.loads(request.content.decode("utf-8")) + assert payload["id"] == str(pdf_file.id) + assert payload["output"] == "async-with-text" + assert ( + payload["text_objects"] + == to_json( + [ + _serialize_text_object_for_request( + make_text_object(page="all", is_right_to_left=True) + ) + ] + ).decode() + ) + 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-text.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_text_to_pdf( + pdf_file, + text_objects=[make_text_object(page="all", is_right_to_left=True)], + output="async-with-text", + ) + + assert seen == {"post": 1, "get": 1} + assert response.output_files[0].name == "async-with-text.pdf" + + +@pytest.mark.asyncio +async def test_async_add_text_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, float | dict[str, float] | None] = {} + overridden_text_object = make_text_object(page=3) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-added-text": + assert request.url.params["trace"] == "true" + assert request.headers["X-Test"] == "async" + payload = json.loads(request.content.decode("utf-8")) + assert payload["text_size"] == 18 + assert ( + payload["text_objects"] + == to_json( + [_serialize_text_object_for_request(overridden_text_object)], + ).decode() + ) + 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-text.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_text_to_pdf( + pdf_file, + text_objects=[make_text_object(text_size=18)], + extra_query={"trace": "true"}, + extra_headers={"X-Test": "async"}, + extra_body={ + "text_size": 18, + "text_objects": to_json( + [_serialize_text_object_for_request(overridden_text_object)], + ).decode(), + }, + timeout=1.0, + ) + + assert response.output_files[0].name == "async-custom-text.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.asyncio +async def test_async_add_text_to_pdf_invalid_cmyk_range( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match="less than or equal to 100", + ): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[ + make_text_object( + text_color_rgb=None, text_color_cmyk=(0, 0, 0, 101) + ) + ], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_pdf_mime_validation( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="Must be a PDF file"): + await client.add_text_to_pdf( + make_image_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object()], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_page_minimum( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="greater than or equal to 1"): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(page=0)], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_rejects_both_color_modes( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=re.escape("Provide only one of text_color_rgb or text_color_cmyk."), + ): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(text_color_cmyk=(0, 0, 0, 0))], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_requires_color( + 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.") + + transport = httpx.MockTransport(handler) + text_object = make_text_object() + text_object.pop("text_color_rgb") + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=re.escape( + "Either text_color_rgb or text_color_cmyk must be provided." + ), + ): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[text_object], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_rgb_bounds( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match="less than or equal to 255", + ): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(text_color_rgb=(0, 0, 256))], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_to_pdf_text_size_bounds( + 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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="greater than or equal to 5"): + await client.add_text_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + text_objects=[make_text_object(text_size=4)], + ) + + +@pytest.mark.asyncio +async def test_async_add_text_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.") + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match="at most 1 item"): + await client.add_text_to_pdf( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + text_objects=[make_text_object()], + ) diff --git a/tests/test_files.py b/tests/test_files.py index 9640cc42..8b3f94b7 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -12,6 +12,7 @@ import httpx import pytest import pytest_asyncio +from pydantic_core import to_json from typing_extensions import override from pdfrest import AsyncPdfRestClient, PdfRestClient @@ -654,7 +655,7 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "GET" and request.url.path == "/resource/file-id": return httpx.Response(200, stream=_StaticStream(binary_content)) if request.method == "GET" and request.url.path == "/resource/file-id-json": - payload = json.dumps(json_payload).encode("utf-8") + payload = to_json(json_payload) return httpx.Response(200, stream=_StaticStream(payload)) msg = f"Unexpected request: {request.method} {request.url}" raise AssertionError(msg) @@ -739,7 +740,7 @@ def handler(request: httpx.Request) -> httpx.Response: if request.method == "GET" and request.url.path == "/resource/file-id": return httpx.Response(200, stream=_StaticAsyncStream(binary_content)) if request.method == "GET" and request.url.path == "/resource/file-id-json": - payload = json.dumps(json_payload).encode("utf-8") + payload = to_json(json_payload) return httpx.Response(200, stream=_StaticAsyncStream(payload)) msg = f"Unexpected request: {request.method} {request.url}" raise AssertionError(msg) diff --git a/tests/test_pdf_redaction_preview.py b/tests/test_pdf_redaction_preview.py index 909d35c4..386f2b00 100644 --- a/tests/test_pdf_redaction_preview.py +++ b/tests/test_pdf_redaction_preview.py @@ -5,6 +5,7 @@ import httpx import pytest from pydantic import ValidationError +from pydantic_core import to_json from pdfrest import PdfRestClient from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID @@ -107,7 +108,7 @@ def test_preview_redactions_reject_json_string(monkeypatch: pytest.MonkeyPatch) ): client.preview_redactions( input_file, - redactions=json.dumps([{"type": "literal", "value": "secret"}]), # type: ignore[arg-type] + redactions=to_json([{"type": "literal", "value": "secret"}]).decode(), # type: ignore[arg-type] )