diff --git a/docs/api-guide.md b/docs/api-guide.md index f8b8f0d..2a59449 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -82,9 +82,12 @@ 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_images_to_pdf][pdfrest.PdfRestClient.add_images_to_pdf], [add_shapes_to_pdf][pdfrest.PdfRestClient.add_shapes_to_pdf] - (`add_text_to_pdf` can tag newly added text and identify its natural language - for assistive technology) + (`add_image_to_pdf` preserves the concise single-image workflow, while + `add_images_to_pdf` accepts typed placements for multiple or reused uploaded + images; image and text overlays can include accessibility tagging and a BCP 47 + language tag) - 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 a827916..a4f56f2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,6 +21,8 @@ supported interpreter matrix. ## Available Examples +- `examples/add_images/add_images_to_pdf_example.py` – add multiple uploaded + images with typed placements, sizing, reuse, and accessibility metadata. - `examples/apply_tdm_reservation/apply_tdm_reservation_example.py` – embed a hosted TDM policy URL in an uploaded PDF. - `examples/set_pdf_page_boxes/set_pdf_page_boxes_example.py` – enlarge a PDF's diff --git a/examples/add_images/add_images_to_pdf_example.py b/examples/add_images/add_images_to_pdf_example.py new file mode 100644 index 0000000..c26de2a --- /dev/null +++ b/examples/add_images/add_images_to_pdf_example.py @@ -0,0 +1,67 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Add two uploaded images to a PDF with reusable typed placements. + +This example loads ``PDFREST_API_KEY`` (optionally from ``.env``), uploads the +checked-in PDF, PNG, and JPEG fixtures under ``examples/resources/``, adds the +images with sizing and accessible tag metadata, and prints the generated PDF +details. + +Run from the repository root with: +``uv run examples/add_images/add_images_to_pdf_example.py``. +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient +from pdfrest.types import PdfAddImageObject + +load_dotenv() + +resources_directory = Path(__file__).resolve().parents[1] / "resources" +pdf_path = resources_directory / "report.pdf" +image_paths = [ + resources_directory / "image-overlay-logo.png", + resources_directory / "image-overlay-photo.jpg", +] + +with PdfRestClient(api_key=os.environ["PDFREST_API_KEY"]) as client: + source_pdf = client.files.create_from_paths([pdf_path])[0] + logo, photo = client.files.create_from_paths(image_paths) + placements: list[PdfAddImageObject] = [ + PdfAddImageObject( + image=logo, + page="1-last", + x=36, + y=36, + width=72, + tag_alt_text="Company logo", + tag_structure_type="Figure", + ), + PdfAddImageObject( + image=photo, + page=1, + x=144, + y=36, + height=72, + tag_alt_text="Sample photograph", + tag_structure_type="Figure", + ), + ] + response = client.add_images_to_pdf( + source_pdf, + image_objects=placements, + tag_enabled=True, + tag_language="en-US", + output="report-with-images", + ) + +print(f"Created: {response.output_file.name}") +print(f"MIME type: {response.output_file.type}") +print(f"Size: {response.output_file.size} bytes") +print(f"URL: {response.output_file.url}") diff --git a/examples/resources/image-overlay-logo.png b/examples/resources/image-overlay-logo.png new file mode 100644 index 0000000..5e40cc7 Binary files /dev/null and b/examples/resources/image-overlay-logo.png differ diff --git a/examples/resources/image-overlay-photo.jpg b/examples/resources/image-overlay-photo.jpg new file mode 100644 index 0000000..964703c Binary files /dev/null and b/examples/resources/image-overlay-photo.jpg differ diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 45b8fab..dd1e6cb 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -98,6 +98,7 @@ OcrPdfPayload, PdfAddAttachmentPayload, PdfAddImagePayload, + PdfAddImagesPayload, PdfAddShapesPayload, PdfAddTextPayload, PdfBlankPayload, @@ -156,9 +157,11 @@ HtmlWebLayout, JpegColorModel, OcrLanguage, + PdfAddImageObject, PdfAddShapeObject, PdfAddTextObject, PdfAType, + PdfContentStructureType, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -3549,22 +3552,43 @@ def add_image_to_pdf( x: int, y: int, page: int, + width: float | None = None, + height: float | None = None, + tag_enabled: bool | None = None, + tag_language: str | None = None, + tag_alt_text: str | None = None, + tag_is_artifact: bool | None = None, + tag_structure_type: PdfContentStructureType | 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: - """Insert an image into a single page of a PDF. + """Insert one image into a single page of a PDF. - Places an uploaded image onto target pages in a PDF with size/position controls. This is commonly used for logos, seals, and branding overlays. + This backward-compatible helper is intended for one image placement. Use + [add_images_to_pdf][pdfrest.PdfRestClient.add_images_to_pdf] for multiple + images, page expressions, or repeated placements. Args: file: Uploaded input file or files as `PdfRestFile` objects. - image: Image/PDF resource to place into the source document. + image: Uploaded JPEG, PNG, TIFF, or GIF resource to place. x: Horizontal position in PDF points. y: Vertical position in PDF points. - page: Target page number (or selector) where inserted content should be applied. + page: Positive one-based target page number. + width: Optional placed width in PDF points. Must be greater than 0. + height: Optional placed height in PDF points. Must be greater than 0. + tag_enabled: Whether to tag the added image. Must be true when any + other tag option is provided. + tag_language: Optional BCP 47 language tag, such as ``"en-US"``. + Requires ``tag_enabled=True``. + tag_alt_text: Optional non-empty alternate text. Requires + ``tag_enabled=True``. + tag_is_artifact: Whether the image is decorative. Requires + ``tag_enabled=True`` when provided. + tag_structure_type: Optional semantic PDF structure type. ``"Figure"`` + is normally appropriate. Requires ``tag_enabled=True``. 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. @@ -3585,6 +3609,18 @@ def add_image_to_pdf( "y": y, "page": page, } + optional_payload = { + "width": width, + "height": height, + "tag_enabled": tag_enabled, + "tag_language": tag_language, + "tag_alt_text": tag_alt_text, + "tag_is_artifact": tag_is_artifact, + "tag_structure_type": tag_structure_type, + } + payload.update( + {key: value for key, value in optional_payload.items() if value is not None} + ) if output is not None: payload["output"] = output @@ -3598,6 +3634,67 @@ def add_image_to_pdf( timeout=timeout, ) + def add_images_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + image_objects: PdfAddImageObject | Sequence[PdfAddImageObject], + tag_enabled: bool | None = None, + tag_language: str | 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: + """Insert one or more uploaded images using typed placement objects. + + Each object can select one or more pages, size its image, and provide + image-specific accessibility metadata. Reuse the same ``PdfRestFile`` in + multiple objects to place one uploaded image more than once. + + Args: + file: One uploaded PDF to modify. + image_objects: One placement object or a non-empty sequence of + placement objects. + tag_enabled: Whether to tag the added images. Must be true when + ``tag_language`` or per-image tag metadata is provided. + tag_language: Optional BCP 47 language tag for image metadata, such + as ``"en-US"``. Requires ``tag_enabled=True``. + 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, + "image_objects": image_objects, + } + if tag_enabled is not None: + payload["tag_enabled"] = tag_enabled + if tag_language is not None: + payload["tag_language"] = tag_language + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-added-image", + payload=payload, + payload_model=PdfAddImagesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def set_pdf_page_boxes( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -7227,6 +7324,13 @@ async def add_image_to_pdf( x: int, y: int, page: int, + width: float | None = None, + height: float | None = None, + tag_enabled: bool | None = None, + tag_language: str | None = None, + tag_alt_text: str | None = None, + tag_is_artifact: bool | None = None, + tag_structure_type: PdfContentStructureType | None = None, output: str | None = None, extra_query: Query | None = None, extra_headers: AnyMapping | None = None, @@ -7235,14 +7339,28 @@ async def add_image_to_pdf( ) -> PdfRestFileBasedResponse: """Asynchronous variant of [PdfRestClient.add_image_to_pdf][pdfrest.PdfRestClient.add_image_to_pdf]. - Places an uploaded image onto target pages in a PDF with size/position controls. This is commonly used for logos, seals, and branding overlays. + This backward-compatible helper is intended for one image placement. Use + [add_images_to_pdf][pdfrest.AsyncPdfRestClient.add_images_to_pdf] for + multiple images, page expressions, or repeated placements. Args: file: Uploaded input file or files as `PdfRestFile` objects. - image: Image/PDF resource to place into the source document. + image: Uploaded JPEG, PNG, TIFF, or GIF resource to place. x: Horizontal position in PDF points. y: Vertical position in PDF points. - page: Target page number (or selector) where inserted content should be applied. + page: Positive one-based target page number. + width: Optional placed width in PDF points. Must be greater than 0. + height: Optional placed height in PDF points. Must be greater than 0. + tag_enabled: Whether to tag the added image. Must be true when any + other tag option is provided. + tag_language: Optional BCP 47 language tag, such as ``"en-US"``. + Requires ``tag_enabled=True``. + tag_alt_text: Optional non-empty alternate text. Requires + ``tag_enabled=True``. + tag_is_artifact: Whether the image is decorative. Requires + ``tag_enabled=True`` when provided. + tag_structure_type: Optional semantic PDF structure type. ``"Figure"`` + is normally appropriate. Requires ``tag_enabled=True``. 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. @@ -7263,6 +7381,18 @@ async def add_image_to_pdf( "y": y, "page": page, } + optional_payload = { + "width": width, + "height": height, + "tag_enabled": tag_enabled, + "tag_language": tag_language, + "tag_alt_text": tag_alt_text, + "tag_is_artifact": tag_is_artifact, + "tag_structure_type": tag_structure_type, + } + payload.update( + {key: value for key, value in optional_payload.items() if value is not None} + ) if output is not None: payload["output"] = output @@ -7276,6 +7406,63 @@ async def add_image_to_pdf( timeout=timeout, ) + async def add_images_to_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + image_objects: PdfAddImageObject | Sequence[PdfAddImageObject], + tag_enabled: bool | None = None, + tag_language: str | 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_images_to_pdf][pdfrest.PdfRestClient.add_images_to_pdf]. + + Args: + file: One uploaded PDF to modify. + image_objects: One placement object or a non-empty sequence of + placement objects. + tag_enabled: Whether to tag the added images. Must be true when + ``tag_language`` or per-image tag metadata is provided. + tag_language: Optional BCP 47 language tag for image metadata, such + as ``"en-US"``. Requires ``tag_enabled=True``. + 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, + "image_objects": image_objects, + } + if tag_enabled is not None: + payload["tag_enabled"] = tag_enabled + if tag_language is not None: + payload["tag_language"] = tag_language + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-added-image", + payload=payload, + payload_model=PdfAddImagesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def set_pdf_page_boxes( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 498e558..0d0f834 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -3200,12 +3200,206 @@ class PdfAddImagePayload(BaseModel): x: Annotated[int, Field(serialization_alias="x")] y: Annotated[int, Field(serialization_alias="y")] page: Annotated[int, Field(serialization_alias="page", ge=1)] + width: Annotated[ + _PositiveFiniteNumber | None, + Field(serialization_alias="width", default=None), + ] = None + height: Annotated[ + _PositiveFiniteNumber | None, + Field(serialization_alias="height", default=None), + ] = None + tag_enabled: Annotated[ + bool | None, + Field(serialization_alias="tag_enabled", default=None), + ] = None + tag_language: Annotated[ + _TagLanguage | None, + Field(serialization_alias="tag_language", default=None), + ] = None + tag_alt_text: Annotated[ + str | None, + Field(serialization_alias="tag_alt_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 + 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_metadata(self) -> PdfAddImagePayload: + has_tag_metadata = any( + value is not None + for value in ( + self.tag_language, + self.tag_alt_text, + self.tag_is_artifact, + self.tag_structure_type, + ) + ) + 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 + + +_ImagePageNumber = Annotated[int, Field(ge=1)] +_ImagePageRangeTuple = Annotated[ + tuple[_ImagePageNumber, _ImagePageNumber] + | tuple[_ImagePageNumber, Literal["last"]], + BeforeValidator(_split_page_range_tuple), + AfterValidator(_ascending_page_range), +] +_ImagePageRange = _ImagePageNumber | Literal["last"] | _ImagePageRangeTuple +_ImagePageSelections = Annotated[list[_ImagePageRange], Field(min_length=1)] +_ImagePageSelectorValue = Literal["all"] | _ImagePageNumber | _ImagePageSelections + + +def _split_image_page_selector(value: Any) -> Any: + if isinstance(value, bool): + msg = "Image page selectors must be a positive integer or string expression." + raise ValueError(msg) # noqa: TRY004 - Pydantic must surface ValidationError. + if isinstance(value, int): + return value + if not isinstance(value, str): + msg = "Image page selectors must be a positive integer or string expression." + raise ValueError(msg) # noqa: TRY004 - Pydantic must surface ValidationError. + normalized = value.strip().casefold() + if normalized == "all": + return normalized + return [selection.strip() for selection in normalized.split(",")] + + +def _serialize_image_page_selector(value: _ImagePageSelectorValue) -> str | int: + if isinstance(value, list): + return _serialize_page_ranges(value) + return value + + +_ImagePageSelector = Annotated[ + _ImagePageSelectorValue, + BeforeValidator(_split_image_page_selector), + PlainSerializer(_serialize_image_page_selector), +] + + +class PdfAddedImageObjectModel(BaseModel): + """Adapt one uploaded image placement into the pdfRest JSON contract.""" + + model_config = ConfigDict(extra="forbid") + + image: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("image", "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), + ] + page: Annotated[_ImagePageSelector, Field(serialization_alias="page")] + x: Annotated[int, Field(serialization_alias="x", ge=0, strict=True)] + y: Annotated[int, Field(serialization_alias="y", ge=0, strict=True)] + width: Annotated[ + _PositiveFiniteNumber | None, + Field(serialization_alias="width", default=None), + ] = None + height: Annotated[ + _PositiveFiniteNumber | None, + Field(serialization_alias="height", default=None), + ] = None + tag_alt_text: Annotated[ + str | None, + Field(serialization_alias="tag_alt_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 + + +def _serialize_image_objects( + value: list[PdfAddedImageObjectModel], +) -> list[dict[str, Any]]: + return [ + entry.model_dump(mode="json", by_alias=True, exclude_none=True) + for entry in value + ] + + +class PdfAddImagesPayload(BaseModel): + """Adapt typed image placements into a pdfRest batch add-image request.""" + + 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_objects: Annotated[ + list[PdfAddedImageObjectModel], + Field(serialization_alias="image_objects", min_length=1), + BeforeValidator(_ensure_list), + PlainSerializer(_serialize_image_objects), + ] + tag_enabled: Annotated[ + bool | None, + Field(serialization_alias="tag_enabled", default=None), + ] = None + tag_language: Annotated[ + _TagLanguage | None, + Field(serialization_alias="tag_language", 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_metadata(self) -> PdfAddImagesPayload: + has_tag_metadata = self.tag_language is not None or any( + image.tag_alt_text is not None + or image.tag_is_artifact is not None + or image.tag_structure_type is not None + for image in self.image_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 _BasePdfWatermarkPayload(BaseModel): """Shared fields for watermark request payloads.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index cbed176..0c02184 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -16,6 +16,7 @@ HtmlWebLayout, JpegColorModel, OcrLanguage, + PdfAddImageObject, PdfAddLineObject, PdfAddRectangleObject, PdfAddShapeObject, @@ -104,6 +105,7 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddImageObject", "PdfAddLineObject", "PdfAddRectangleObject", "PdfAddShapeObject", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 01d9794..934af78 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -29,6 +29,7 @@ "JpegColorModel", "OcrLanguage", "PdfAType", + "PdfAddImageObject", "PdfAddLineObject", "PdfAddRectangleObject", "PdfAddShapeObject", @@ -782,7 +783,7 @@ class PdfStructuredTextCsvColumn(TypedDict, total=False): ] """Semantic PDF structure type assigned to newly added content. -Shape tagging requires ``tag_enabled=True`` on the client method. These are +Content tagging requires ``tag_enabled=True`` on the client method. These are the closed set accepted by the service, rather than arbitrary custom roles. Meanings follow the [standard PDF structure elements](https://pdfa.org/wp-content/uploads/2019/06/TaggedPDFBestPracticeGuideSyntax.pdf). @@ -818,6 +819,45 @@ class PdfStructuredTextCsvColumn(TypedDict, total=False): """ +class PdfAddImageObject(TypedDict, total=False): + """One image placement accepted by the batch image-overlay helper. + + See [pdfrest.PdfRestClient.add_images_to_pdf][]. + + Attributes: + image: Required uploaded JPEG, PNG, TIFF, or GIF resource. The client + serializes its resource ID into the nested ``image_id`` field. + page: Required page selector. Accepts a positive one-based page number, + ``"all"``, ``"last"``, comma-separated pages, or ascending ranges + such as ``"2-4,6-last"``. + x: Required horizontal position of the image's lower-left corner in PDF + points. Must be at least 0. + y: Required vertical position of the image's lower-left corner in PDF + points. Must be at least 0. + width: Optional placed width in PDF points. Must be greater than 0. If + height is omitted, the image scales proportionally. + height: Optional placed height in PDF points. Must be greater than 0. If + width is omitted, the image scales proportionally. + tag_alt_text: Optional non-empty alternate text. Requires + ``tag_enabled=True`` on the client method. + tag_is_artifact: Optional marker for decorative content. Requires + ``tag_enabled=True`` on the client method. + tag_structure_type: Optional semantic PDF structure type. ``"Figure"`` + is normally appropriate. Requires ``tag_enabled=True`` on the client + method. + """ + + image: Required[PdfRestFile] + page: Required[int | str] + x: Required[int] + y: Required[int] + width: float + height: float + tag_alt_text: str + tag_is_artifact: bool + tag_structure_type: PdfContentStructureType + + class PdfAddLineObject(TypedDict, total=False): """Line shape inserted by [pdfrest.PdfRestClient.add_shapes_to_pdf][]. diff --git a/tests/live/test_live_add_image_to_pdf.py b/tests/live/test_live_add_image_to_pdf.py index 9888970..e5e4837 100644 --- a/tests/live/test_live_add_image_to_pdf.py +++ b/tests/live/test_live_add_image_to_pdf.py @@ -1,9 +1,12 @@ from __future__ import annotations +from typing import get_args + import pytest from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile +from pdfrest.types import PdfAddImageObject, PdfContentStructureType from ..resources import get_test_resource_path @@ -14,6 +17,29 @@ pytest.param(("test.gif", "image/gif"), id="gif"), ) +_UNSUPPORTED_IMAGE_STRUCTURE_TYPES = frozenset({"Ruby", "Warichu"}) +_UNSUPPORTED_IMAGE_STRUCTURE_TYPE_REASON = ( + "The deployed image-overlay service returns a fatal 400 for standalone " + "Ruby and Warichu structure elements." +) + +CONTENT_STRUCTURE_TYPE_CASES = tuple( + pytest.param( + value, + marks=( + pytest.mark.xfail( + raises=PdfRestApiError, + reason=_UNSUPPORTED_IMAGE_STRUCTURE_TYPE_REASON, + strict=True, + ) + if value in _UNSUPPORTED_IMAGE_STRUCTURE_TYPES + else () + ), + id=value, + ) + for value in get_args(PdfContentStructureType) +) + @pytest.fixture(scope="module") def uploaded_pdf_for_image_addition( @@ -46,6 +72,25 @@ def uploaded_image_variant( return uploaded_image +@pytest.fixture(scope="module") +def uploaded_batch_images( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> tuple[PdfRestFile, PdfRestFile]: + resources = [ + get_test_resource_path("test.png"), + get_test_resource_path("test.jpg"), + ] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + uploaded_images = client.files.create_from_paths(resources) + + assert [image.type for image in uploaded_images] == ["image/png", "image/jpeg"] + return uploaded_images[0], uploaded_images[1] + + def test_live_add_image_to_pdf( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -62,6 +107,12 @@ def test_live_add_image_to_pdf( x=25, y=50, page=1, + width=72, + height=36, + tag_enabled=True, + tag_language="en-US", + tag_alt_text="Test image", + tag_structure_type="Figure", output="live-added-image", ) @@ -92,6 +143,9 @@ async def test_live_async_add_image_to_pdf( x=75, y=125, page=1, + width=36, + tag_enabled=True, + tag_is_artifact=True, ) assert response.output_files @@ -103,6 +157,88 @@ async def test_live_async_add_image_to_pdf( assert uploaded_image_variant.id in response.input_ids +def test_live_add_images_to_pdf( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + first_image, second_image = uploaded_batch_images + image_objects: list[PdfAddImageObject] = [ + PdfAddImageObject( + image=first_image, + page="1-last", + x=25, + y=50, + width=72, + tag_alt_text="Primary test image", + tag_structure_type="Figure", + ), + PdfAddImageObject( + image=second_image, + page=1, + x=125, + y=50, + height=36, + tag_is_artifact=False, + ), + ] + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=image_objects, + tag_enabled=True, + tag_language="en-US", + output="live-added-images", + ) + + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-added-images") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert response.input_ids == [ + uploaded_pdf_for_image_addition.id, + first_image.id, + second_image.id, + ] + + +@pytest.mark.asyncio +async def test_live_async_add_images_to_pdf_reuses_uploaded_image( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + first_image, _ = uploaded_batch_images + image_objects: list[PdfAddImageObject] = [ + PdfAddImageObject(image=first_image, page="all", x=25, y=50, width=72), + PdfAddImageObject(image=first_image, page=1, x=125, y=50, width=36), + ] + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=image_objects, + output="live-reused-image", + ) + + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-reused-image") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert response.input_ids == [uploaded_pdf_for_image_addition.id, first_image.id] + + def test_live_add_image_to_pdf_invalid_page( pdfrest_api_key: str, pdfrest_live_base_url: str, @@ -146,3 +282,318 @@ async def test_live_async_add_image_to_pdf_invalid_page( page=1, extra_body={"page": 0}, ) + + +def test_live_add_images_to_pdf_invalid_page_expression( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + image, _ = uploaded_batch_images + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)page"), + ): + client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject(image=image, page=1, x=0, y=0), + extra_body={ + "image_objects": [ + { + "image_id": str(image.id), + "page": "last-2", + "x": 0, + "y": 0, + } + ] + }, + ) + + +def test_live_add_images_to_pdf_invalid_tag_dependency( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + image, _ = uploaded_batch_images + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)tag_enabled"), + ): + client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject( + image=image, + page=1, + x=0, + y=0, + tag_alt_text="Test image", + ), + tag_enabled=True, + extra_body={"tag_enabled": False}, + ) + + +@pytest.mark.asyncio +async def test_live_async_add_images_to_pdf_invalid_page_expression( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + image, _ = uploaded_batch_images + 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_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject(image=image, page=1, x=0, y=0), + extra_body={ + "image_objects": [ + { + "image_id": str(image.id), + "page": "last-2", + "x": 0, + "y": 0, + } + ] + }, + ) + + +@pytest.mark.asyncio +async def test_live_async_add_images_to_pdf_invalid_tag_dependency( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + image, _ = uploaded_batch_images + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)tag_enabled"): + await client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject( + image=image, + page=1, + x=0, + y=0, + tag_alt_text="Test image", + ), + tag_enabled=True, + extra_body={"tag_enabled": False}, + ) + + +def test_live_add_images_to_pdf_single_object_mime_contract( + 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_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject( + image=uploaded_image_variant, + page=1, + x=0, + y=0, + ), + output="live-single-image-object", + ) + + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-single-image-object") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert response.input_ids == [ + uploaded_pdf_for_image_addition.id, + uploaded_image_variant.id, + ] + + +@pytest.mark.asyncio +async def test_live_async_add_images_to_pdf_single_object_mime_contract( + 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_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject( + image=uploaded_image_variant, + page=1, + x=0, + y=0, + ), + output="live-async-single-image-object", + ) + + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-async-single-image-object") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert response.input_ids == [ + uploaded_pdf_for_image_addition.id, + uploaded_image_variant.id, + ] + + +@pytest.mark.parametrize("structure_type", CONTENT_STRUCTURE_TYPE_CASES) +def test_live_add_images_to_pdf_structure_type_contract( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], + structure_type: PdfContentStructureType, +) -> None: + image, _ = uploaded_batch_images + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject( + image=image, + page=1, + x=0, + y=0, + tag_structure_type=structure_type, + ), + tag_enabled=True, + output="live-image-structure-type", + ) + + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-image-structure-type") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert response.input_ids == [uploaded_pdf_for_image_addition.id, image.id] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("structure_type", CONTENT_STRUCTURE_TYPE_CASES) +async def test_live_async_add_images_to_pdf_structure_type_contract( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], + structure_type: PdfContentStructureType, +) -> None: + image, _ = uploaded_batch_images + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject( + image=image, + page=1, + x=0, + y=0, + tag_structure_type=structure_type, + ), + tag_enabled=True, + output="live-async-image-structure-type", + ) + + assert len(response.output_files) == 1 + assert response.output_file.type == "application/pdf" + assert response.output_file.name.startswith("live-async-image-structure-type") + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + assert response.input_ids == [uploaded_pdf_for_image_addition.id, image.id] + + +def test_live_add_images_to_pdf_invalid_structure_type( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + image, _ = uploaded_batch_images + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)tag_structure_type|structure"), + ): + client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject(image=image, page=1, x=0, y=0), + tag_enabled=True, + extra_body={ + "image_objects": [ + { + "image_id": str(image.id), + "page": 1, + "x": 0, + "y": 0, + "tag_structure_type": "DefinitelyNotAStructureType", + } + ] + }, + ) + + +@pytest.mark.asyncio +async def test_live_async_add_images_to_pdf_invalid_structure_type( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_image_addition: PdfRestFile, + uploaded_batch_images: tuple[PdfRestFile, PdfRestFile], +) -> None: + image, _ = uploaded_batch_images + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)tag_structure_type|structure"): + await client.add_images_to_pdf( + uploaded_pdf_for_image_addition, + image_objects=PdfAddImageObject(image=image, page=1, x=0, y=0), + tag_enabled=True, + extra_body={ + "image_objects": [ + { + "image_id": str(image.id), + "page": 1, + "x": 0, + "y": 0, + "tag_structure_type": "DefinitelyNotAStructureType", + } + ] + }, + ) diff --git a/tests/test_add_image_to_pdf.py b/tests/test_add_image_to_pdf.py index a3f781a..41d785e 100644 --- a/tests/test_add_image_to_pdf.py +++ b/tests/test_add_image_to_pdf.py @@ -2,13 +2,16 @@ import json import re +from typing import cast, get_args import httpx import pytest from pydantic import ValidationError from pdfrest import AsyncPdfRestClient, PdfRestClient -from pdfrest.models import PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfAddImagePayload, PdfAddImagesPayload +from pdfrest.types import PdfAddImageObject, PdfContentStructureType from .graphics_test_helpers import ( ASYNC_API_KEY, @@ -25,6 +28,10 @@ pytest.param("image/gif", "logo.gif", id="gif"), ) +CONTENT_STRUCTURE_TYPE_CASES = tuple( + pytest.param(value, id=value) for value in get_args(PdfContentStructureType) +) + @pytest.mark.parametrize(("image_mime_type", "image_name"), IMAGE_MIME_VARIANTS) def test_add_image_to_pdf_success( @@ -514,3 +521,1180 @@ def handler(_: httpx.Request) -> httpx.Response: y=1, page=1, ) + + +def _assert_timeout(timeout: object, expected: float) -> None: + assert timeout is not None + if isinstance(timeout, dict): + assert all( + component == pytest.approx(expected) for component in timeout.values() + ) + else: + assert timeout == pytest.approx(expected) + + +def test_add_image_payload_serializes_size_and_tagging() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + + body = PdfAddImagePayload.model_validate( + { + "file": pdf_file, + "image": image_file, + "x": -12, + "y": 34, + "page": 2, + "width": 144, + "height": 72, + "tag_enabled": True, + "tag_language": "en-US", + "tag_alt_text": "Company logo", + "tag_is_artifact": False, + "tag_structure_type": "Figure", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert body == { + "id": str(pdf_file.id), + "image_id": str(image_file.id), + "x": -12, + "y": 34, + "page": 2, + "width": 144.0, + "height": 72.0, + "tag_enabled": True, + "tag_language": "en-US", + "tag_alt_text": "Company logo", + "tag_is_artifact": False, + "tag_structure_type": "Figure", + } + + +@pytest.mark.parametrize( + ("page", "expected"), + [ + pytest.param(1, 1, id="integer"), + pytest.param("1", "1", id="numeric-string"), + pytest.param("all", "all", id="all"), + pytest.param("last", "last", id="last"), + pytest.param("2-4,6-last", "2-4,6-last", id="combined-ranges"), + pytest.param(" 2 , 4-7 , LAST ", "2,4-7,last", id="normalized"), + ], +) +def test_add_images_payload_page_selectors( + page: int | str, expected: int | str +) -> None: + image_file = make_image_file(PdfRestFileID.generate(2)) + payload = PdfAddImagesPayload.model_validate( + { + "file": make_pdf_file(PdfRestFileID.generate(1)), + "image_objects": { + "image": image_file, + "page": page, + "x": 0, + "y": 0, + }, + } + ) + + body = payload.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_unset=True + ) + assert body["image_objects"][0]["page"] == expected + + +def test_add_images_payload_serializes_single_and_reused_images() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + first_image = make_image_file(PdfRestFileID.generate(2)) + second_image = make_image_file( + PdfRestFileID.generate(), mime_type="image/jpeg", name="second.jpg" + ) + image_objects: list[PdfAddImageObject] = [ + PdfAddImageObject( + image=first_image, + page="1-last", + x=0, + y=10, + width=72, + tag_alt_text="Primary logo", + tag_structure_type="Figure", + ), + PdfAddImageObject( + image=first_image, + page=1, + x=100, + y=10, + height=36, + tag_is_artifact=False, + ), + PdfAddImageObject(image=second_image, page="all", x=0, y=0), + ] + + body = PdfAddImagesPayload.model_validate( + { + "file": pdf_file, + "image_objects": image_objects, + "tag_enabled": True, + "tag_language": "en-US", + "output": "branded-pages", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert body == { + "id": str(pdf_file.id), + "image_objects": [ + { + "image_id": str(first_image.id), + "page": "1-last", + "x": 0, + "y": 10, + "width": 72.0, + "tag_alt_text": "Primary logo", + "tag_structure_type": "Figure", + }, + { + "image_id": str(first_image.id), + "page": 1, + "x": 100, + "y": 10, + "height": 36.0, + "tag_is_artifact": False, + }, + { + "image_id": str(second_image.id), + "page": "all", + "x": 0, + "y": 0, + }, + ], + "tag_enabled": True, + "tag_language": "en-US", + "output": "branded-pages", + } + + +@pytest.mark.parametrize( + ("image_object", "match"), + [ + pytest.param( + {"page": "last-2", "x": 0, "y": 0}, + "Input should be a valid integer", + id="last-first", + ), + pytest.param( + {"page": "4-2", "x": 0, "y": 0}, + "start page must be less", + id="descending-range", + ), + pytest.param( + {"page": "even", "x": 0, "y": 0}, + "Input should be a valid integer", + id="unsupported-selector", + ), + pytest.param( + {"page": [1, 2], "x": 0, "y": 0}, + "positive integer or string expression", + id="list-selector", + ), + pytest.param( + {"page": 1, "x": -1, "y": 0}, + "greater than or equal to 0", + id="negative-x", + ), + pytest.param( + {"page": 1, "x": 0, "y": 0, "width": 0}, + "greater than 0", + id="zero-width", + ), + pytest.param( + {"page": 1, "x": 0, "y": 0, "tag_alt_text": ""}, + "at least 1 character", + id="empty-alt-text", + ), + ], +) +def test_add_images_payload_rejects_invalid_placement( + image_object: dict[str, object], match: str +) -> None: + with pytest.raises(ValidationError, match=match): + PdfAddImagesPayload.model_validate( + { + "file": make_pdf_file(PdfRestFileID.generate(1)), + "image_objects": { + "image": make_image_file(PdfRestFileID.generate(2)), + **image_object, + }, + "tag_enabled": True, + } + ) + + +def _image_operation_transport( + expected_body: dict[str, object], + input_ids: list[PdfRestFileID], + *, + header_name: str, + header_value: str, + timeout: dict[str, object], +) -> httpx.MockTransport: + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + assert request.url.path == "/pdf-with-added-image" + assert request.url.params["trace"] == "true" + assert request.headers[header_name] == header_value + assert json.loads(request.content) == expected_body + timeout["value"] = request.extensions.get("timeout") + return httpx.Response( + 200, + json={"inputId": input_ids, "outputId": [output_id]}, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + return httpx.Response( + 200, + json=build_file_info_payload(output_id, "branded.pdf", "application/pdf"), + ) + + return httpx.MockTransport(handler) + + +def test_add_image_to_pdf_size_and_tagging_options() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + timeout: dict[str, object] = {} + expected_body: dict[str, object] = { + "id": str(pdf_file.id), + "image_id": str(image_file.id), + "x": -5, + "y": 10, + "page": 1, + "width": 72.0, + "height": 36.0, + "tag_enabled": True, + "tag_language": "en-US", + "tag_alt_text": "Company logo", + "tag_is_artifact": False, + "tag_structure_type": "Figure", + } + transport = _image_operation_transport( + expected_body, + [pdf_file.id, image_file.id], + header_name="X-Test", + header_value="sync-legacy", + timeout=timeout, + ) + + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_image_to_pdf( + pdf_file, + image=image_file, + x=-5, + y=10, + page=1, + width=72, + height=36, + tag_enabled=True, + tag_language="en-US", + tag_alt_text="Company logo", + tag_is_artifact=False, + tag_structure_type="Figure", + extra_query={"trace": "true"}, + extra_headers={"X-Test": "sync-legacy"}, + timeout=0.25, + ) + + assert response.output_file.name == "branded.pdf" + assert response.input_ids == [pdf_file.id, image_file.id] + _assert_timeout(timeout["value"], 0.25) + + +@pytest.mark.asyncio +async def test_async_add_image_to_pdf_size_and_tagging_options() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + timeout: dict[str, object] = {} + expected_body: dict[str, object] = { + "id": str(pdf_file.id), + "image_id": str(image_file.id), + "x": 5, + "y": 10, + "page": 1, + "width": 72.0, + "tag_enabled": True, + "tag_language": "fr", + "tag_alt_text": "Logo", + "tag_is_artifact": True, + "tag_structure_type": "Figure", + } + transport = _image_operation_transport( + expected_body, + [pdf_file.id, image_file.id], + header_name="X-Test", + header_value="async-legacy", + timeout=timeout, + ) + + 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=10, + page=1, + width=72, + tag_enabled=True, + tag_language="fr", + tag_alt_text="Logo", + tag_is_artifact=True, + tag_structure_type="Figure", + extra_query={"trace": "true"}, + extra_headers={"X-Test": "async-legacy"}, + timeout=0.75, + ) + + assert response.output_file.name == "branded.pdf" + assert response.input_ids == [pdf_file.id, image_file.id] + _assert_timeout(timeout["value"], 0.75) + + +def test_add_image_to_pdf_rejects_tag_metadata_without_tagging() -> None: + 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="tag_enabled must be true when tag options are provided", + ), + ): + client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + x=0, + y=0, + page=1, + tag_alt_text="Logo", + ) + + +@pytest.mark.asyncio +async def test_async_add_image_to_pdf_rejects_tag_metadata_without_tagging() -> None: + 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="tag_enabled must be true when tag options are provided", + ): + await client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + x=0, + y=0, + page=1, + tag_is_artifact=False, + ) + + +def test_add_images_to_pdf_multiple_images_and_customization() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + first_image = make_image_file(PdfRestFileID.generate(2)) + second_image = make_image_file( + PdfRestFileID.generate(), mime_type="image/gif", name="second.gif" + ) + timeout: dict[str, object] = {} + expected_body: dict[str, object] = { + "id": str(pdf_file.id), + "image_objects": [ + { + "image_id": str(first_image.id), + "page": "1-last", + "x": 0, + "y": 0, + "width": 72.0, + "tag_alt_text": "Logo", + }, + { + "image_id": str(second_image.id), + "page": 2, + "x": 100, + "y": 200, + "height": 36.0, + "tag_is_artifact": True, + }, + ], + "tag_enabled": True, + "tag_language": "en-US", + "output": "branded-pages", + } + transport = _image_operation_transport( + expected_body, + [pdf_file.id, first_image.id, second_image.id], + header_name="X-Test", + header_value="sync-batch", + timeout=timeout, + ) + + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_images_to_pdf( + pdf_file, + image_objects=[ + PdfAddImageObject( + image=first_image, + page="1-last", + x=0, + y=0, + width=72, + tag_alt_text="Logo", + ), + PdfAddImageObject( + image=second_image, + page=2, + x=100, + y=200, + height=36, + tag_is_artifact=True, + ), + ], + tag_enabled=True, + tag_language="en-US", + output="branded-pages", + extra_query={"trace": "true"}, + extra_headers={"X-Test": "sync-batch"}, + extra_body={"output": "branded-pages"}, + timeout=1.25, + ) + + assert response.output_file.name == "branded.pdf" + assert response.input_ids == [pdf_file.id, first_image.id, second_image.id] + _assert_timeout(timeout["value"], 1.25) + + +@pytest.mark.asyncio +async def test_async_add_images_to_pdf_single_reused_image_and_customization() -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + timeout: dict[str, object] = {} + expected_body: dict[str, object] = { + "id": str(pdf_file.id), + "image_objects": [ + { + "image_id": str(image_file.id), + "page": "all", + "x": 0, + "y": 0, + "tag_structure_type": "Figure", + }, + { + "image_id": str(image_file.id), + "page": 1, + "x": 72, + "y": 72, + }, + ], + "tag_enabled": True, + "tag_language": "en-US", + "output": "branded-pages", + } + transport = _image_operation_transport( + expected_body, + [pdf_file.id, image_file.id], + header_name="X-Test", + header_value="async-batch", + timeout=timeout, + ) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_images_to_pdf( + pdf_file, + image_objects=[ + PdfAddImageObject( + image=image_file, + page="all", + x=0, + y=0, + tag_structure_type="Figure", + ), + PdfAddImageObject(image=image_file, page=1, x=72, y=72), + ], + tag_enabled=True, + tag_language="en-US", + output="branded-pages", + extra_query={"trace": "true"}, + extra_headers={"X-Test": "async-batch"}, + extra_body={"tag_enabled": True}, + timeout=1.5, + ) + + assert response.output_file.name == "branded.pdf" + assert response.input_ids == [pdf_file.id, image_file.id] + _assert_timeout(timeout["value"], 1.5) + + +def test_add_images_to_pdf_rejects_tag_metadata_without_tagging() -> None: + 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="tag_enabled must be true when tag options are provided", + ), + ): + client.add_images_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image_objects=PdfAddImageObject( + image=make_image_file(PdfRestFileID.generate(2)), + page=1, + x=0, + y=0, + tag_alt_text="Logo", + ), + ) + + +@pytest.mark.asyncio +async def test_async_add_images_to_pdf_rejects_tag_metadata_without_tagging() -> None: + 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="tag_enabled must be true when tag options are provided", + ): + await client.add_images_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image_objects=PdfAddImageObject( + image=make_image_file(PdfRestFileID.generate(2)), + page=1, + x=0, + y=0, + tag_is_artifact=False, + ), + ) + + +@pytest.mark.parametrize( + ("files", "image_objects", "match"), + [ + pytest.param( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + PdfAddImageObject( + image=make_image_file(PdfRestFileID.generate()), + page=1, + x=0, + y=0, + ), + "at most 1 item", + id="multiple-pdfs", + ), + pytest.param( + make_pdf_file(PdfRestFileID.generate(1)), + [], + "at least 1 item", + id="no-placements", + ), + pytest.param( + make_pdf_file(PdfRestFileID.generate(1)), + PdfAddImageObject( + image=[ + make_image_file(PdfRestFileID.generate(2)), + make_image_file(PdfRestFileID.generate()), + ], + page=1, + x=0, + y=0, + ), + "at most 1 item", + id="multiple-images-per-placement", + ), + pytest.param( + make_pdf_file(PdfRestFileID.generate(1)), + PdfAddImageObject( + image=make_pdf_file(PdfRestFileID.generate(2)), + page=1, + x=0, + y=0, + ), + "Image must be JPEG, PNG, TIFF, or GIF", + id="invalid-image-mime", + ), + ], +) +def test_add_images_to_pdf_cardinality_and_mime_validation( + files: object, image_objects: object, match: str +) -> None: + 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_images_to_pdf( + files, + image_objects=image_objects, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("files", "image_objects", "match"), + [ + pytest.param( + [ + make_pdf_file(PdfRestFileID.generate(1)), + make_pdf_file(PdfRestFileID.generate(2)), + ], + PdfAddImageObject( + image=make_image_file(PdfRestFileID.generate()), + page=1, + x=0, + y=0, + ), + "at most 1 item", + id="multiple-pdfs", + ), + pytest.param( + make_pdf_file(PdfRestFileID.generate(1)), + [], + "at least 1 item", + id="no-placements", + ), + pytest.param( + make_pdf_file(PdfRestFileID.generate(1)), + PdfAddImageObject( + image=[ + make_image_file(PdfRestFileID.generate(2)), + make_image_file(PdfRestFileID.generate()), + ], + page=1, + x=0, + y=0, + ), + "at most 1 item", + id="multiple-images-per-placement", + ), + pytest.param( + make_pdf_file(PdfRestFileID.generate(1)), + PdfAddImageObject( + image=make_pdf_file(PdfRestFileID.generate(2)), + page=1, + x=0, + y=0, + ), + "Image must be JPEG, PNG, TIFF, or GIF", + id="invalid-image-mime", + ), + ], +) +async def test_async_add_images_to_pdf_cardinality_and_mime_validation( + files: object, image_objects: object, match: str +) -> None: + 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=match): + await client.add_images_to_pdf( + files, + image_objects=image_objects, + ) + + +def _single_batch_transport( + pdf_file: PdfRestFile, + image_file: PdfRestFile, + expected_image_object: dict[str, object], +) -> httpx.MockTransport: + output_id = str(PdfRestFileID.generate()) + expected_body: dict[str, object] = { + "id": str(pdf_file.id), + "image_objects": [expected_image_object], + } + if "tag_structure_type" in expected_image_object: + expected_body["tag_enabled"] = True + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + assert request.url.path == "/pdf-with-added-image" + assert json.loads(request.content) == expected_body + return httpx.Response( + 200, + json={ + "inputId": [pdf_file.id, image_file.id], + "outputId": [output_id], + }, + ) + assert request.method == "GET" + assert request.url.path == f"/resource/{output_id}" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "single-image.pdf", "application/pdf" + ), + ) + + return httpx.MockTransport(handler) + + +@pytest.mark.parametrize(("image_mime_type", "image_name"), IMAGE_MIME_VARIANTS) +def test_add_images_to_pdf_single_object_mime_contract( + image_mime_type: str, + image_name: str, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file( + PdfRestFileID.generate(2), mime_type=image_mime_type, name=image_name + ) + expected_image_object = { + "image_id": str(image_file.id), + "page": 1, + "x": 0, + "y": 0, + } + transport = _single_batch_transport(pdf_file, image_file, expected_image_object) + + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_images_to_pdf( + pdf_file, + image_objects=PdfAddImageObject( + image=image_file, + page=1, + x=0, + y=0, + ), + ) + + assert response.input_ids == [pdf_file.id, image_file.id] + assert response.output_file.name == "single-image.pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("image_mime_type", "image_name"), IMAGE_MIME_VARIANTS) +async def test_async_add_images_to_pdf_single_object_mime_contract( + image_mime_type: str, + image_name: str, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file( + PdfRestFileID.generate(2), mime_type=image_mime_type, name=image_name + ) + expected_image_object = { + "image_id": str(image_file.id), + "page": 1, + "x": 0, + "y": 0, + } + transport = _single_batch_transport(pdf_file, image_file, expected_image_object) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_images_to_pdf( + pdf_file, + image_objects=PdfAddImageObject( + image=image_file, + page=1, + x=0, + y=0, + ), + ) + + assert response.input_ids == [pdf_file.id, image_file.id] + assert response.output_file.name == "single-image.pdf" + + +@pytest.mark.parametrize("structure_type", CONTENT_STRUCTURE_TYPE_CASES) +def test_add_images_payload_structure_type_contract( + structure_type: PdfContentStructureType, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + + body = PdfAddImagesPayload.model_validate( + { + "file": pdf_file, + "image_objects": PdfAddImageObject( + image=image_file, + page=1, + x=0, + y=0, + tag_structure_type=structure_type, + ), + "tag_enabled": True, + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert body == { + "id": str(pdf_file.id), + "image_objects": [ + { + "image_id": str(image_file.id), + "page": 1, + "x": 0, + "y": 0, + "tag_structure_type": structure_type, + } + ], + "tag_enabled": True, + } + + +@pytest.mark.parametrize("structure_type", CONTENT_STRUCTURE_TYPE_CASES) +def test_add_images_to_pdf_structure_type_contract( + structure_type: PdfContentStructureType, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + expected_image_object = { + "image_id": str(image_file.id), + "page": 1, + "x": 0, + "y": 0, + "tag_structure_type": structure_type, + } + transport = _single_batch_transport(pdf_file, image_file, expected_image_object) + + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_images_to_pdf( + pdf_file, + image_objects=PdfAddImageObject( + image=image_file, + page=1, + x=0, + y=0, + tag_structure_type=structure_type, + ), + tag_enabled=True, + ) + + assert response.input_ids == [pdf_file.id, image_file.id] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("structure_type", CONTENT_STRUCTURE_TYPE_CASES) +async def test_async_add_images_to_pdf_structure_type_contract( + structure_type: PdfContentStructureType, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + expected_image_object = { + "image_id": str(image_file.id), + "page": 1, + "x": 0, + "y": 0, + "tag_structure_type": structure_type, + } + transport = _single_batch_transport(pdf_file, image_file, expected_image_object) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_images_to_pdf( + pdf_file, + image_objects=PdfAddImageObject( + image=image_file, + page=1, + x=0, + y=0, + tag_structure_type=structure_type, + ), + tag_enabled=True, + ) + + assert response.input_ids == [pdf_file.id, image_file.id] + + +VALID_IMAGE_PLACEMENT_BOUNDARIES = ( + pytest.param("page", 1, id="page-minimum"), + pytest.param("x", 0, id="x-minimum"), + pytest.param("y", 0, id="y-minimum"), + pytest.param("width", 0.01, id="width-positive"), + pytest.param("height", 0.01, id="height-positive"), +) + +INVALID_IMAGE_PLACEMENT_BOUNDARIES = ( + pytest.param("page", 0, "greater than or equal to 1", id="page-below-minimum"), + pytest.param("x", -1, "greater than or equal to 0", id="x-below-minimum"), + pytest.param("y", -1, "greater than or equal to 0", id="y-below-minimum"), + pytest.param("width", 0, "greater than 0", id="width-zero"), + pytest.param("height", 0, "greater than 0", id="height-zero"), + pytest.param("width", float("inf"), "finite number", id="width-infinite"), + pytest.param("height", float("inf"), "finite number", id="height-infinite"), +) + + +@pytest.mark.parametrize(("field", "value"), VALID_IMAGE_PLACEMENT_BOUNDARIES) +def test_add_images_to_pdf_accepts_placement_boundaries( + field: str, + value: int | float, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + image_object: dict[str, object] = { + "image": image_file, + "page": 1, + "x": 0, + "y": 0, + field: value, + } + expected_image_object = { + "image_id": str(image_file.id), + "page": 1, + "x": 0, + "y": 0, + } + expected_image_object[field] = value + transport = _single_batch_transport(pdf_file, image_file, expected_image_object) + + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.add_images_to_pdf( + pdf_file, + image_objects=cast(PdfAddImageObject, cast(object, image_object)), + ) + + assert response.input_ids == [pdf_file.id, image_file.id] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("field", "value"), VALID_IMAGE_PLACEMENT_BOUNDARIES) +async def test_async_add_images_to_pdf_accepts_placement_boundaries( + field: str, + value: int | float, +) -> None: + pdf_file = make_pdf_file(PdfRestFileID.generate(1)) + image_file = make_image_file(PdfRestFileID.generate(2)) + image_object: dict[str, object] = { + "image": image_file, + "page": 1, + "x": 0, + "y": 0, + field: value, + } + expected_image_object = { + "image_id": str(image_file.id), + "page": 1, + "x": 0, + "y": 0, + } + expected_image_object[field] = value + transport = _single_batch_transport(pdf_file, image_file, expected_image_object) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.add_images_to_pdf( + pdf_file, + image_objects=cast(PdfAddImageObject, cast(object, image_object)), + ) + + assert response.input_ids == [pdf_file.id, image_file.id] + + +@pytest.mark.parametrize( + ("field", "value", "match"), INVALID_IMAGE_PLACEMENT_BOUNDARIES +) +def test_add_images_to_pdf_rejects_invalid_placement_boundaries( + field: str, + value: int | float, + match: str, +) -> None: + image_object: dict[str, object] = { + "image": make_image_file(PdfRestFileID.generate(2)), + "page": 1, + "x": 0, + "y": 0, + field: value, + } + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Invalid placement must be rejected before transport execution.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match=match), + ): + client.add_images_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image_objects=cast(PdfAddImageObject, cast(object, image_object)), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("field", "value", "match"), INVALID_IMAGE_PLACEMENT_BOUNDARIES +) +async def test_async_add_images_to_pdf_rejects_invalid_placement_boundaries( + field: str, + value: int | float, + match: str, +) -> None: + image_object: dict[str, object] = { + "image": make_image_file(PdfRestFileID.generate(2)), + "page": 1, + "x": 0, + "y": 0, + field: value, + } + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Invalid placement must be rejected before transport execution.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match=match): + await client.add_images_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image_objects=cast(PdfAddImageObject, cast(object, image_object)), + ) + + +@pytest.mark.parametrize("field", [pytest.param("width"), pytest.param("height")]) +@pytest.mark.parametrize( + "value", + [pytest.param(float("inf"), id="infinite"), pytest.param(float("nan"), id="nan")], +) +def test_add_image_to_pdf_rejects_non_finite_dimensions( + field: str, + value: float, +) -> None: + dimensions = { + "width": value if field == "width" else None, + "height": value if field == "height" else None, + } + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Invalid dimensions must be rejected before transport execution.") + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match="finite number"), + ): + client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + page=1, + x=0, + y=0, + width=dimensions["width"], + height=dimensions["height"], + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field", [pytest.param("width"), pytest.param("height")]) +@pytest.mark.parametrize( + "value", + [pytest.param(float("inf"), id="infinite"), pytest.param(float("nan"), id="nan")], +) +async def test_async_add_image_to_pdf_rejects_non_finite_dimensions( + field: str, + value: float, +) -> None: + dimensions = { + "width": value if field == "width" else None, + "height": value if field == "height" else None, + } + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail("Invalid dimensions must be rejected before transport execution.") + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match="finite number"): + await client.add_image_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image=make_image_file(PdfRestFileID.generate(2)), + page=1, + x=0, + y=0, + width=dimensions["width"], + height=dimensions["height"], + ) + + +def test_add_images_to_pdf_rejects_invalid_structure_type() -> None: + image_object = cast( + PdfAddImageObject, + cast( + object, + { + "image": make_image_file(PdfRestFileID.generate(2)), + "page": 1, + "x": 0, + "y": 0, + "tag_structure_type": "DefinitelyNotAStructureType", + }, + ), + ) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail( + "Invalid structure type must be rejected before transport execution." + ) + + with ( + PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client, + pytest.raises(ValidationError, match="tag_structure_type"), + ): + client.add_images_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image_objects=image_object, + tag_enabled=True, + ) + + +@pytest.mark.asyncio +async def test_async_add_images_to_pdf_rejects_invalid_structure_type() -> None: + image_object = cast( + PdfAddImageObject, + cast( + object, + { + "image": make_image_file(PdfRestFileID.generate(2)), + "page": 1, + "x": 0, + "y": 0, + "tag_structure_type": "DefinitelyNotAStructureType", + }, + ), + ) + + def handler(_: httpx.Request) -> httpx.Response: + pytest.fail( + "Invalid structure type must be rejected before transport execution." + ) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValidationError, match="tag_structure_type"): + await client.add_images_to_pdf( + make_pdf_file(PdfRestFileID.generate(1)), + image_objects=image_object, + tag_enabled=True, + )