diff --git a/docs/api-guide.md b/docs/api-guide.md index e1df8e7..b50dcbc 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -118,6 +118,10 @@ Use this group for password protection, permissions, signatures, and standards. Use this group to improve compatibility, performance, or file size. +- Page geometry: + [set_pdf_page_boxes][pdfrest.PdfRestClient.set_pdf_page_boxes] updates the + MediaBox, CropBox, BleedBox, TrimBox, or ArtBox on selected pages + - Size and optimization: [compress_pdf][pdfrest.PdfRestClient.compress_pdf], [linearize_pdf][pdfrest.PdfRestClient.linearize_pdf] diff --git a/docs/llms-full.txt b/docs/llms-full.txt index aa4e0a1..9acf2a5 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -184,6 +184,7 @@ to the API guide and API reference for exact method names and options. - Passwords and permissions - Encryption/decryption/restrictions - URL-based text-and-data-mining reservation metadata +- Typed MediaBox, CropBox, BleedBox, TrimBox, and ArtBox updates - Watermarking (text/image) - Digital signing diff --git a/examples/README.md b/examples/README.md index d9be39b..b51762c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,8 @@ supported interpreter matrix. - `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 + MediaBox and apply a CropBox inset with typed page selections and margins. - `examples/add_shapes/add_shapes_to_pdf_example.py` – add a styled rectangle and divider line to a PDF with accessibility tagging enabled. - `examples/convert_structured_documents/convert_structured_documents_to_pdf_example.py` diff --git a/examples/set_pdf_page_boxes/set_pdf_page_boxes_example.py b/examples/set_pdf_page_boxes/set_pdf_page_boxes_example.py new file mode 100644 index 0000000..a6b516b --- /dev/null +++ b/examples/set_pdf_page_boxes/set_pdf_page_boxes_example.py @@ -0,0 +1,85 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfrest", "python-dotenv"] +# /// +"""Update the MediaBox and CropBox of an uploaded PDF. + +This sample demonstrates how to: + +1. Load ``PDFREST_API_KEY`` from the environment or a local ``.env`` file. +2. Upload the checked-in ``examples/resources/report.pdf`` input. +3. Enlarge its MediaBox and apply a positive CropBox inset to every page. +4. Print the generated PDF metadata. + +Set ``PDFREST_BASE_URL`` only for a self-hosted deployment. Run from the +repository root with +``uv run examples/set_pdf_page_boxes/set_pdf_page_boxes_example.py``. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +from pdfrest import PdfRestClient +from pdfrest.types import ( + PdfInsetPageBoxDefinition, + PdfInsetPageBoxPage, + PdfMediaBoxDefinition, + PdfMediaBoxPage, + PdfPageBoxDefinition, +) + +RESOURCE_DIRECTORY = Path(__file__).resolve().parents[1] / "resources" +INPUT_PATH = RESOURCE_DIRECTORY / "report.pdf" + + +def set_pdf_page_boxes() -> None: + """Upload a PDF, update two page boxes, and print the output metadata.""" + load_dotenv() + base_url = os.getenv("PDFREST_BASE_URL") + boxes: list[PdfPageBoxDefinition] = [ + PdfMediaBoxDefinition( + box="media", + pages=[ + PdfMediaBoxPage( + range="all", + top=-18, + bottom=-18, + left=-18, + right=-18, + ) + ], + ), + PdfInsetPageBoxDefinition( + box="crop", + pages=[ + PdfInsetPageBoxPage( + range="all", + top=36, + bottom=36, + left=36, + right=36, + ) + ], + ), + ] + + with PdfRestClient(base_url=base_url) as client: + uploaded_pdf = client.files.create_from_paths(INPUT_PATH)[0] + response = client.set_pdf_page_boxes( + uploaded_pdf, + boxes=boxes, + output="report-with-page-boxes", + ) + + print(f"Input ID: {response.input_id}") + print(f"Output file: {response.output_file.name}") + print(f"Output MIME type: {response.output_file.type}") + print(f"Output URL: {response.output_file.url}") + + +if __name__ == "__main__": # pragma: no cover - manual example + set_pdf_page_boxes() diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index c8a411f..3608d27 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -120,6 +120,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfRestrictPayload, + PdfSetPageBoxesPayload, PdfSignPayload, PdfSplitPayload, PdfTdmReservationPayload, @@ -163,6 +164,7 @@ PdfConversionLocale, PdfInfoQuery, PdfMergeInput, + PdfPageBoxDefinition, PdfPageOrientation, PdfPageSelection, PdfPageSize, @@ -3585,6 +3587,53 @@ def add_image_to_pdf( timeout=timeout, ) + def set_pdf_page_boxes( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + boxes: PdfPageBoxDefinition | Sequence[PdfPageBoxDefinition], + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Set one or more page boundary boxes on a PDF. + + Margins use PDF units, where 72 units equal one inch, and are relative + to the current MediaBox. MediaBox margins may be negative to enlarge a + page; CropBox, BleedBox, TrimBox, and ArtBox margins must be positive. + + Args: + file: Uploaded PDF resource to update. + boxes: One or more typed MediaBox or inset page-box definitions. + output: Generated output filename without its extension. + extra_query: Additional query parameters merged into the request. + extra_headers: Additional HTTP headers merged into the request. + extra_body: Additional request body fields merged into the payload. + timeout: Request timeout override for this call. + + Returns: + Validated file-based response containing the updated PDF. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = {"files": file, "boxes": boxes} + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/pdf-with-page-boxes-set", + payload=payload, + payload_model=PdfSetPageBoxesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def split_pdf( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -7205,6 +7254,52 @@ async def add_image_to_pdf( timeout=timeout, ) + async def set_pdf_page_boxes( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + boxes: PdfPageBoxDefinition | Sequence[PdfPageBoxDefinition], + 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 set one or more page boundary boxes on a PDF. + + This is the asynchronous variant of + [PdfRestClient.set_pdf_page_boxes][pdfrest.PdfRestClient.set_pdf_page_boxes]. + + Args: + file: Uploaded PDF resource to update. + boxes: One or more typed MediaBox or inset page-box definitions. + output: Generated output filename without its extension. + extra_query: Additional query parameters merged into the request. + extra_headers: Additional HTTP headers merged into the request. + extra_body: Additional request body fields merged into the payload. + timeout: Request timeout override for this call. + + Returns: + Validated file-based response containing the updated PDF. + + Raises: + PdfRestError: If request execution fails at the client or API layer. + ValidationError: If local payload validation fails before sending. + """ + payload: dict[str, Any] = {"files": file, "boxes": boxes} + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/pdf-with-page-boxes-set", + payload=payload, + payload_model=PdfSetPageBoxesPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def up( self, *, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 70b15e7..83e1c1a 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -457,7 +457,9 @@ class UnzipPayload(BaseModel): PageNumber = Annotated[int, Field(ge=1), PlainSerializer(lambda x: str(x))] -def _split_page_range_tuple(x: str) -> tuple[str, str]: +def _split_page_range_tuple(x: Any) -> Any: + if not isinstance(x, str): + return x start, end = x.split("-", maxsplit=1) return start, end @@ -492,6 +494,183 @@ def _ascending_page_range( AscendingPageRange = PageNumber | Literal["last"] | _AscendingPageRangeTuple +def _serialize_page_box_range( + value: str | int | tuple[int, int | Literal["last"]], +) -> str: + if isinstance(value, tuple): + return "-".join(str(part) for part in value) + return str(value) + + +def _translate_page_box_range_error( + value: Any, + handler: ValidatorFunctionWrapHandler, +) -> str | int | tuple[int, int | Literal["last"]]: + try: + return cast(str | int | tuple[int, int | Literal["last"]], handler(value)) + except ValueError as error: + error_type: Literal["page_box_range"] = "page_box_range" + raise PydanticCustomError( + error_type, + "The given page range is invalid. [{value}].", + {"value": value}, + ) from error + + +def _split_page_box_range_tuple(value: Any) -> Any: + if not isinstance(value, str): + return value + if not re.fullmatch(r"[1-9][0-9]*-(?:[1-9][0-9]*|last)", value): + msg = "Page ranges must use an ascending `start-end` or `start-last` form." + raise ValueError(msg) + return _split_page_range_tuple(value) + + +_PageBoxPageNumber = Annotated[int, Field(ge=1, strict=True)] +_PageBoxPageNumberString = Annotated[ + str, + StringConstraints(pattern=r"^[1-9][0-9]*$"), +] +_PageBoxAscendingRangeTuple = Annotated[ + tuple[PageNumber, PageNumber] | tuple[PageNumber, Literal["last"]], + BeforeValidator(_split_page_box_range_tuple), + AfterValidator(_ascending_page_range), +] + + +PageBoxRange = Annotated[ + Literal["all", "even", "odd"] + | _PageBoxPageNumber + | _PageBoxPageNumberString + | _PageBoxAscendingRangeTuple, + WrapValidator(_translate_page_box_range_error), + PlainSerializer(_serialize_page_box_range), +] + + +_FiniteNumber = Annotated[float, Field(allow_inf_nan=False)] +_PositiveFiniteNumber = Annotated[float, Field(gt=0, allow_inf_nan=False)] + + +class _PdfMediaBoxPage(BaseModel): + model_config = ConfigDict(extra="forbid") + + range: PageBoxRange + top: _FiniteNumber + bottom: _FiniteNumber + left: _FiniteNumber + right: _FiniteNumber + + +class _PdfInsetPageBoxPage(BaseModel): + model_config = ConfigDict(extra="forbid") + + range: PageBoxRange + top: _PositiveFiniteNumber + bottom: _PositiveFiniteNumber + left: _PositiveFiniteNumber + right: _PositiveFiniteNumber + + +class _PdfMediaBoxDefinition(BaseModel): + model_config = ConfigDict(extra="forbid") + + box: Literal["media"] + pages: Annotated[list[_PdfMediaBoxPage], Field(min_length=1)] + + +class _PdfInsetPageBoxDefinition(BaseModel): + model_config = ConfigDict(extra="forbid") + + box: Literal["crop", "bleed", "trim", "art"] + pages: Annotated[list[_PdfInsetPageBoxPage], Field(min_length=1)] + + +_PageBoxModel = _PdfMediaBoxDefinition | _PdfInsetPageBoxDefinition + + +_PdfPageBoxDefinition = Annotated[ + _PageBoxModel, + Field(discriminator="box"), +] + + +def _page_box_selection_interval( + value: str | int | tuple[int, int | Literal["last"]], +) -> tuple[int, int | None, int | None]: + if value == "all": + return 1, None, None + if value == "even": + return 2, None, 0 + if value == "odd": + return 1, None, 1 + if isinstance(value, tuple): + start, end = value + return start, None if end == "last" else end, None + page = int(value) + return page, page, None + + +def _page_box_selections_overlap( + left: str | int | tuple[int, int | Literal["last"]], + right: str | int | tuple[int, int | Literal["last"]], +) -> bool: + left_start, left_end, left_parity = _page_box_selection_interval(left) + right_start, right_end, right_parity = _page_box_selection_interval(right) + start = max(left_start, right_start) + finite_ends = [end for end in (left_end, right_end) if end is not None] + end = min(finite_ends) if finite_ends else None + if end is not None and start > end: + return False + if ( + left_parity is not None + and right_parity is not None + and left_parity != right_parity + ): + return False + parity = left_parity if left_parity is not None else right_parity + if parity is None or end is None: + return True + first_matching_page = start if start % 2 == parity else start + 1 + return first_matching_page <= end + + +def _validate_page_box_selection_conflicts( + value: list[_PageBoxModel], +) -> list[_PageBoxModel]: + selections_by_box: dict[ + str, + list[str | int | tuple[int, int | Literal["last"]]], + ] = {} + for definition in value: + prior_selections = selections_by_box.setdefault(definition.box, []) + for page in definition.pages: + if any( + _page_box_selections_overlap(page.range, prior) + for prior in prior_selections + ): + error_type: Literal["page_box_selection_conflict"] = ( + "page_box_selection_conflict" + ) + selection = _serialize_page_box_range(page.range) + raise PydanticCustomError( + error_type, + "The box page settings conflict with each other. " + "Page selection [{selection}] overlaps an earlier " + "{box} selection.", + {"selection": selection, "box": definition.box}, + ) + prior_selections.append(page.range) + return value + + +def _serialize_page_box_definitions( + value: list[_PageBoxModel], +) -> str: + boxes = [box.model_dump(mode="json") for box in value] + return to_json({"boxes": boxes}).decode() + + _PostscriptPageSelections = Annotated[ list[AscendingPageRange], Field(min_length=1), @@ -2084,6 +2263,39 @@ class PdfSplitPayload(BaseModel): ] = None +class PdfSetPageBoxesPayload(BaseModel): + """Adapt typed page-box definitions into pdfRest's JSON-string field.""" + + model_config = ConfigDict(extra="forbid") + + 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), + ] + boxes: Annotated[ + list[_PdfPageBoxDefinition], + Field(min_length=1), + BeforeValidator(_ensure_list), + AfterValidator(_validate_page_box_selection_conflicts), + PlainSerializer(_serialize_page_box_definitions), + ] + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + class _PdfMergeItem(BaseModel): file: Annotated[ PdfRestFile, diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index 62029d4..cbed176 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -30,8 +30,14 @@ PdfConversionLocale, PdfCustomPageSize, PdfInfoQuery, + PdfInsetPageBoxDefinition, + PdfInsetPageBoxPage, + PdfMediaBoxDefinition, + PdfMediaBoxPage, PdfMergeInput, PdfMergeSource, + PdfPageBoxDefinition, + PdfPageBoxRange, PdfPageOrientation, PdfPageSelection, PdfPageSize, @@ -111,8 +117,14 @@ "PdfConversionLocale", "PdfCustomPageSize", "PdfInfoQuery", + "PdfInsetPageBoxDefinition", + "PdfInsetPageBoxPage", + "PdfMediaBoxDefinition", + "PdfMediaBoxPage", "PdfMergeInput", "PdfMergeSource", + "PdfPageBoxDefinition", + "PdfPageBoxRange", "PdfPageOrientation", "PdfPageSelection", "PdfPageSize", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 7782633..01d9794 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -42,8 +42,14 @@ "PdfConversionLocale", "PdfCustomPageSize", "PdfInfoQuery", + "PdfInsetPageBoxDefinition", + "PdfInsetPageBoxPage", + "PdfMediaBoxDefinition", + "PdfMediaBoxPage", "PdfMergeInput", "PdfMergeSource", + "PdfPageBoxDefinition", + "PdfPageBoxRange", "PdfPageOrientation", "PdfPageSelection", "PdfPageSize", @@ -897,6 +903,92 @@ class PdfAddRectangleObject(TypedDict, total=False): [pdfrest.PdfRestClient.add_shapes_to_pdf][].""" +PdfPageBoxRange: TypeAlias = int | str +"""Page selection accepted by a page-box definition. + +Use a positive one-based page number, an ascending range such as ``"2-5"``, +a range ending in ``"last"``, or one of ``"all"``, ``"even"``, and ``"odd"``. +The payload model validates string syntax and range ordering before transport. +""" + + +class PdfMediaBoxPage(TypedDict, total=False): + """Signed MediaBox margins for one page selection. + + Attributes: + range: Required page selection. Accepts a positive page number, an + ascending range, a range ending in ``"last"``, ``"all"``, + ``"even"``, or ``"odd"``. + top: Required top margin in PDF units. Negative values enlarge the page. + bottom: Required bottom margin in PDF units. Negative values enlarge + the page. + left: Required left margin in PDF units. Negative values enlarge the + page. + right: Required right margin in PDF units. Negative values enlarge the + page. + """ + + range: Required[PdfPageBoxRange] + top: Required[float] + bottom: Required[float] + left: Required[float] + right: Required[float] + + +class PdfInsetPageBoxPage(TypedDict, total=False): + """Positive margins for a CropBox, BleedBox, TrimBox, or ArtBox selection. + + Attributes: + range: Required page selection. Accepts a positive page number, an + ascending range, a range ending in ``"last"``, ``"all"``, + ``"even"``, or ``"odd"``. + top: Required top margin in PDF units. Must be greater than zero. + bottom: Required bottom margin in PDF units. Must be greater than zero. + left: Required left margin in PDF units. Must be greater than zero. + right: Required right margin in PDF units. Must be greater than zero. + """ + + range: Required[PdfPageBoxRange] + top: Required[float] + bottom: Required[float] + left: Required[float] + right: Required[float] + + +class PdfMediaBoxDefinition(TypedDict, total=False): + """MediaBox update accepted by the set-page-boxes helper. + + See [pdfrest.PdfRestClient.set_pdf_page_boxes][]. + + Attributes: + box: Required discriminator. Must be ``"media"``. + pages: Required non-empty sequence of signed MediaBox margin updates. + """ + + box: Required[Literal["media"]] + pages: Required[Sequence[PdfMediaBoxPage]] + + +class PdfInsetPageBoxDefinition(TypedDict, total=False): + """Inset page-box update accepted by the set-page-boxes helper. + + See [pdfrest.PdfRestClient.set_pdf_page_boxes][]. + + Attributes: + box: Required discriminator. Must be ``"crop"``, ``"bleed"``, + ``"trim"``, or ``"art"``. + pages: Required non-empty sequence of strictly positive margin updates. + """ + + box: Required[Literal["crop", "bleed", "trim", "art"]] + pages: Required[Sequence[PdfInsetPageBoxPage]] + + +PdfPageBoxDefinition: TypeAlias = PdfMediaBoxDefinition | PdfInsetPageBoxDefinition +"""A MediaBox or inset page-box definition accepted by +[pdfrest.PdfRestClient.set_pdf_page_boxes][].""" + + class PdfAddTextObject(TypedDict, total=False): """Text overlay object used by add-text style operations. diff --git a/tests/live/test_live_set_pdf_page_boxes.py b/tests/live/test_live_set_pdf_page_boxes.py new file mode 100644 index 0000000..130bc39 --- /dev/null +++ b/tests/live/test_live_set_pdf_page_boxes.py @@ -0,0 +1,571 @@ +from __future__ import annotations + +import json +from typing import Any, Literal + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestInfoResponse +from pdfrest.types import ( + PdfInsetPageBoxDefinition, + PdfInsetPageBoxPage, + PdfMediaBoxDefinition, + PdfMediaBoxPage, + PdfPageBoxDefinition, +) + +from ..resources import get_test_resource_path + +BoxType = Literal["media", "crop", "bleed", "trim", "art"] +PageSelector = Literal["1-3", "even", "odd"] | int + +_BOX_INFO_KEYS: dict[BoxType, str] = { + "media": "MediaBox", + "crop": "CropBox", + "bleed": "BleedBox", + "trim": "TrimBox", + "art": "ArtBox", +} + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_page_boxes( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + return client.files.create_from_paths([get_test_resource_path("report.pdf")])[0] + + +@pytest.fixture(scope="module") +def uploaded_multi_page_pdf_for_page_boxes( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + return client.files.create_from_paths([get_test_resource_path("20-pages.pdf")])[ + 0 + ] + + +def _box_definition(box: BoxType) -> PdfPageBoxDefinition: + if box == "media": + return PdfMediaBoxDefinition( + box="media", + pages=[PdfMediaBoxPage(range="all", top=-9, bottom=0, left=-9, right=0)], + ) + return PdfInsetPageBoxDefinition( + box=box, + pages=[PdfInsetPageBoxPage(range="all", top=9, bottom=9, left=9, right=9)], + ) + + +def _page_box_info(response: PdfRestInfoResponse) -> dict[str, Any]: + assert response.model_extra is not None + page_boxes = response.model_extra.get("page_boxes") + assert isinstance(page_boxes, dict) + return page_boxes + + +def _assert_box_geometry(response: PdfRestInfoResponse, box: BoxType) -> None: + first_page = _page_box_info(response)["1"] + assert isinstance(first_page, dict) + if box == "media": + assert first_page["MediaBox"] == [-9, 801, 612, 0] + assert first_page["CropBox"] == { + "Box": [-9, 801, 612, 0], + "MatchesMediaBox": True, + } + return + + assert first_page[_BOX_INFO_KEYS[box]] == { + "Box": [9, 783, 603, 9], + "MatchesMediaBox": False, + } + assert first_page["MediaBox"] == [0, 792, 612, 0] + + +def _assert_crop_selection( + response: PdfRestInfoResponse, + *, + selected_page: int, + unselected_page: int, +) -> None: + page_boxes = _page_box_info(response) + assert page_boxes[str(selected_page)]["CropBox"] == { + "Box": [9, 783, 603, 9], + "MatchesMediaBox": False, + } + assert page_boxes[str(unselected_page)]["CropBox"] == { + "Box": [0, 792, 612, 0], + "MatchesMediaBox": True, + } + + +def _positive_boundary_definition() -> PdfInsetPageBoxDefinition: + return PdfInsetPageBoxDefinition( + box="crop", + pages=[ + PdfInsetPageBoxPage( + range="all", + top=0.1, + bottom=0.1, + left=0.1, + right=0.1, + ) + ], + ) + + +def _assert_positive_boundary_geometry(response: PdfRestInfoResponse) -> None: + first_page = _page_box_info(response)["1"] + assert first_page["CropBox"]["Box"] == pytest.approx( + [0.1, 791.9, 611.9, 0.1], abs=1e-4 + ) + assert first_page["CropBox"]["MatchesMediaBox"] is False + + +@pytest.mark.parametrize( + "box", + [ + pytest.param("media", id="media"), + pytest.param("crop", id="crop"), + pytest.param("bleed", id="bleed"), + pytest.param("trim", id="trim"), + pytest.param("art", id="art"), + ], +) +def test_live_set_pdf_page_boxes( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_page_boxes: PdfRestFile, + box: BoxType, +) -> None: + output_name = f"page-box-{box}" + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = client.set_pdf_page_boxes( + uploaded_pdf_for_page_boxes, + boxes=_box_definition(box), + output=output_name, + ) + page_box_info = client.query_pdf_info( + response.output_file, + queries="page_count", + extra_body={"queries": "page_boxes"}, + ) + + assert response.input_id == uploaded_pdf_for_page_boxes.id + assert response.output_file.name.startswith(output_name) + assert response.output_file.type == "application/pdf" + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + _assert_box_geometry(page_box_info, box) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "box", + [ + pytest.param("media", id="media"), + pytest.param("crop", id="crop"), + pytest.param("bleed", id="bleed"), + pytest.param("trim", id="trim"), + pytest.param("art", id="art"), + ], +) +async def test_live_async_set_pdf_page_boxes( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_page_boxes: PdfRestFile, + box: BoxType, +) -> None: + output_name = f"async-page-box-{box}" + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + response = await client.set_pdf_page_boxes( + uploaded_pdf_for_page_boxes, + boxes=_box_definition(box), + output=output_name, + ) + page_box_info = await client.query_pdf_info( + response.output_file, + queries="page_count", + extra_body={"queries": "page_boxes"}, + ) + + assert response.input_id == uploaded_pdf_for_page_boxes.id + assert response.output_file.name.startswith(output_name) + assert response.output_file.type == "application/pdf" + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + _assert_box_geometry(page_box_info, box) + + +@pytest.mark.parametrize( + ("selector", "selected_page", "unselected_page"), + [ + pytest.param(1, 1, 2, id="page-number"), + pytest.param("1-3", 2, 4, id="range"), + pytest.param("even", 2, 1, id="even"), + pytest.param("odd", 1, 2, id="odd"), + ], +) +def test_live_set_pdf_page_boxes_applies_page_selectors( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_multi_page_pdf_for_page_boxes: PdfRestFile, + selector: PageSelector, + selected_page: int, + unselected_page: int, +) -> None: + definition = PdfInsetPageBoxDefinition( + box="crop", + pages=[ + PdfInsetPageBoxPage( + range=selector, + top=9, + bottom=9, + left=9, + right=9, + ) + ], + ) + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + output_file = client.set_pdf_page_boxes( + uploaded_multi_page_pdf_for_page_boxes, + boxes=definition, + ).output_file + page_box_info = client.query_pdf_info( + output_file, + queries="page_count", + extra_body={"queries": "page_boxes"}, + ) + + _assert_crop_selection( + page_box_info, + selected_page=selected_page, + unselected_page=unselected_page, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("selector", "selected_page", "unselected_page"), + [ + pytest.param(1, 1, 2, id="page-number"), + pytest.param("1-3", 2, 4, id="range"), + pytest.param("even", 2, 1, id="even"), + pytest.param("odd", 1, 2, id="odd"), + ], +) +async def test_live_async_set_pdf_page_boxes_applies_page_selectors( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_multi_page_pdf_for_page_boxes: PdfRestFile, + selector: PageSelector, + selected_page: int, + unselected_page: int, +) -> None: + definition = PdfInsetPageBoxDefinition( + box="crop", + pages=[ + PdfInsetPageBoxPage( + range=selector, + top=9, + bottom=9, + left=9, + right=9, + ) + ], + ) + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + output_file = ( + await client.set_pdf_page_boxes( + uploaded_multi_page_pdf_for_page_boxes, + boxes=definition, + ) + ).output_file + page_box_info = await client.query_pdf_info( + output_file, + queries="page_count", + extra_body={"queries": "page_boxes"}, + ) + + _assert_crop_selection( + page_box_info, + selected_page=selected_page, + unselected_page=unselected_page, + ) + + +def test_live_set_pdf_page_boxes_accepts_positive_inset_boundary( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_page_boxes: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + output_file = client.set_pdf_page_boxes( + uploaded_pdf_for_page_boxes, + boxes=_positive_boundary_definition(), + ).output_file + page_box_info = client.query_pdf_info( + output_file, + queries="page_count", + extra_body={"queries": "page_boxes"}, + ) + + _assert_positive_boundary_geometry(page_box_info) + + +@pytest.mark.asyncio +async def test_live_async_set_pdf_page_boxes_accepts_positive_inset_boundary( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_page_boxes: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + output_file = ( + await client.set_pdf_page_boxes( + uploaded_pdf_for_page_boxes, + boxes=_positive_boundary_definition(), + ) + ).output_file + page_box_info = await client.query_pdf_info( + output_file, + queries="page_count", + extra_body={"queries": "page_boxes"}, + ) + + _assert_positive_boundary_geometry(page_box_info) + + +@pytest.mark.parametrize( + ("raw_boxes", "match"), + [ + pytest.param( + { + "boxes": [ + { + "box": "media", + "pages": [ + { + "range": "5-1", + "top": 0, + "bottom": 0, + "left": 0, + "right": 0, + } + ], + } + ] + }, + r"The given page range is invalid\. \[5\-1\]\.", + id="descending-range", + ), + pytest.param( + { + "boxes": [ + { + "box": "media", + "pages": [ + { + "range": "1-3", + "top": 0, + "bottom": 0, + "left": 0, + "right": 0, + }, + { + "range": "3-4", + "top": 0, + "bottom": 0, + "left": 0, + "right": 0, + }, + ], + } + ] + }, + r"The box page settings conflict with each other", + id="overlapping-ranges", + ), + ], +) +def test_live_set_pdf_page_boxes_rejects_invalid_page_selections( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_multi_page_pdf_for_page_boxes: PdfRestFile, + raw_boxes: dict[str, Any], + match: str, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client, + pytest.raises(PdfRestApiError, match=match), + ): + client.set_pdf_page_boxes( + uploaded_multi_page_pdf_for_page_boxes, + boxes=_box_definition("media"), + extra_body={"boxes": json.dumps(raw_boxes)}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("raw_boxes", "match"), + [ + pytest.param( + { + "boxes": [ + { + "box": "media", + "pages": [ + { + "range": "5-1", + "top": 0, + "bottom": 0, + "left": 0, + "right": 0, + } + ], + } + ] + }, + r"The given page range is invalid\. \[5\-1\]\.", + id="descending-range", + ), + pytest.param( + { + "boxes": [ + { + "box": "media", + "pages": [ + { + "range": "1-3", + "top": 0, + "bottom": 0, + "left": 0, + "right": 0, + }, + { + "range": "3-4", + "top": 0, + "bottom": 0, + "left": 0, + "right": 0, + }, + ], + } + ] + }, + r"The box page settings conflict with each other", + id="overlapping-ranges", + ), + ], +) +async def test_live_async_set_pdf_page_boxes_rejects_invalid_page_selections( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_multi_page_pdf_for_page_boxes: PdfRestFile, + raw_boxes: dict[str, Any], + match: str, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + with pytest.raises(PdfRestApiError, match=match): + await client.set_pdf_page_boxes( + uploaded_multi_page_pdf_for_page_boxes, + boxes=_box_definition("media"), + extra_body={"boxes": json.dumps(raw_boxes)}, + ) + + +def test_live_set_pdf_page_boxes_rejects_unknown_box( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_page_boxes: PdfRestFile, +) -> None: + invalid_boxes = json.dumps( + { + "boxes": [ + { + "box": "invalid", + "pages": [ + { + "range": "all", + "top": 9, + "bottom": 9, + "left": 9, + "right": 9, + } + ], + } + ] + } + ) + with ( + PdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)(invalid|box|option)"), + ): + client.set_pdf_page_boxes( + uploaded_pdf_for_page_boxes, + boxes=_box_definition("media"), + extra_body={"boxes": invalid_boxes}, + ) + + +@pytest.mark.asyncio +async def test_live_async_set_pdf_page_boxes_rejects_unknown_box( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_page_boxes: PdfRestFile, +) -> None: + invalid_boxes = json.dumps( + { + "boxes": [ + { + "box": "invalid", + "pages": [ + { + "range": "all", + "top": 9, + "bottom": 9, + "left": 9, + "right": 9, + } + ], + } + ] + } + ) + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, base_url=pdfrest_live_base_url + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)(invalid|box|option)"): + await client.set_pdf_page_boxes( + uploaded_pdf_for_page_boxes, + boxes=_box_definition("media"), + extra_body={"boxes": invalid_boxes}, + ) diff --git a/tests/test_set_pdf_page_boxes.py b/tests/test_set_pdf_page_boxes.py new file mode 100644 index 0000000..ff9ba83 --- /dev/null +++ b/tests/test_set_pdf_page_boxes.py @@ -0,0 +1,850 @@ +from __future__ import annotations + +import json +import re +from typing import Literal + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfSetPageBoxesPayload +from pdfrest.types import ( + PdfInsetPageBoxDefinition, + PdfInsetPageBoxPage, + PdfMediaBoxDefinition, + PdfMediaBoxPage, + PdfPageBoxDefinition, +) + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + +InsetBoxType = Literal["crop", "bleed", "trim", "art"] +MarginField = Literal["top", "bottom", "left", "right"] + + +def _media_box(range_: str | int = "1-last") -> PdfMediaBoxDefinition: + return PdfMediaBoxDefinition( + box="media", + pages=[ + PdfMediaBoxPage( + range=range_, + top=-18, + bottom=0, + left=-36, + right=0, + ) + ], + ) + + +def _inset_box( + box: InsetBoxType = "crop", range_: str | int = "all" +) -> PdfInsetPageBoxDefinition: + return PdfInsetPageBoxDefinition( + box=box, + pages=[ + PdfInsetPageBoxPage( + range=range_, + top=18, + bottom=18, + left=36, + right=36, + ) + ], + ) + + +def _assert_timeout(value: float | dict[str, float] | None, expected: float) -> None: + assert value is not None + if isinstance(value, dict): + assert value + assert all(component == pytest.approx(expected) for component in value.values()) + else: + assert value == pytest.approx(expected) + + +def _response_handler( + input_file: PdfRestFile, + output_id: str, + expected_body: dict[str, object], + *, + output_name: str = "page-boxes.pdf", +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdf-with-page-boxes-set": + assert json.loads(request.content) == expected_body + return httpx.Response( + 200, json={"inputId": [input_file.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, output_name, "application/pdf"), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + return httpx.MockTransport(handler) + + +def test_set_page_boxes_payload_serializes_exact_json_string() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + + payload = PdfSetPageBoxesPayload.model_validate( + { + "file": input_file, + "boxes": [ + PdfMediaBoxDefinition( + box="media", + pages=[ + PdfMediaBoxPage( + range=1, + top=-18, + bottom=0, + left=-36, + right=0, + ), + PdfMediaBoxPage( + range="2-last", + top=0, + bottom=0, + left=0, + right=0, + ), + ], + ), + _inset_box("crop", "even"), + ], + "output": "updated-boxes", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + assert payload["id"] == str(input_file.id) + assert payload["output"] == "updated-boxes" + assert json.loads(payload["boxes"]) == { + "boxes": [ + { + "box": "media", + "pages": [ + { + "range": "1", + "top": -18.0, + "bottom": 0.0, + "left": -36.0, + "right": 0.0, + }, + { + "range": "2-last", + "top": 0.0, + "bottom": 0.0, + "left": 0.0, + "right": 0.0, + }, + ], + }, + { + "box": "crop", + "pages": [ + { + "range": "even", + "top": 18.0, + "bottom": 18.0, + "left": 36.0, + "right": 36.0, + } + ], + }, + ] + } + + +@pytest.mark.parametrize( + "range_", + [ + pytest.param(1, id="integer"), + pytest.param("1", id="numeric-string"), + pytest.param("1-3", id="ascending-range"), + pytest.param("2-last", id="last-range"), + pytest.param("all", id="all"), + pytest.param("even", id="even"), + pytest.param("odd", id="odd"), + ], +) +def test_set_page_boxes_payload_accepts_page_selectors(range_: str | int) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + + payload = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": _media_box(range_)} + ).model_dump(mode="json", by_alias=True) + + assert json.loads(payload["boxes"])["boxes"][0]["pages"][0]["range"] == str(range_) + + +@pytest.mark.parametrize( + "range_", + [ + pytest.param(1, id="integer"), + pytest.param("1", id="numeric-string"), + pytest.param("1-3", id="ascending-range"), + pytest.param("2-last", id="last-range"), + pytest.param("all", id="all"), + pytest.param("even", id="even"), + pytest.param("odd", id="odd"), + ], +) +def test_set_pdf_page_boxes_sends_each_page_selector(range_: str | int) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + definition = _media_box(range_) + expected = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": definition} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + with PdfRestClient( + api_key=VALID_API_KEY, + transport=_response_handler(input_file, output_id, expected), + ) as client: + response = client.set_pdf_page_boxes(input_file, boxes=definition) + + assert response.input_id == input_file.id + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "range_", + [ + pytest.param(1, id="integer"), + pytest.param("1", id="numeric-string"), + pytest.param("1-3", id="ascending-range"), + pytest.param("2-last", id="last-range"), + pytest.param("all", id="all"), + pytest.param("even", id="even"), + pytest.param("odd", id="odd"), + ], +) +async def test_async_set_pdf_page_boxes_sends_each_page_selector( + range_: str | int, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + definition = _media_box(range_) + expected = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": definition} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=_response_handler(input_file, output_id, expected), + ) as client: + response = await client.set_pdf_page_boxes(input_file, boxes=definition) + + assert response.input_id == input_file.id + + +@pytest.mark.parametrize( + "range_", + [ + pytest.param(0, id="zero"), + pytest.param(-1, id="negative"), + pytest.param("5-2", id="descending"), + pytest.param("last", id="last-alone"), + pytest.param("1,2", id="comma-list"), + pytest.param("first", id="unknown-selector"), + pytest.param(" 1 ", id="whitespace"), + ], +) +def test_set_page_boxes_payload_rejects_invalid_page_selectors( + range_: str | int, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + match = re.escape(f"The given page range is invalid. [{range_}].") + + with pytest.raises(ValidationError, match=match): + PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": _media_box(range_)} + ) + + +@pytest.mark.parametrize( + "ranges", + [ + pytest.param(("1-3", "2-4"), id="overlapping-ranges"), + pytest.param(("all", 1), id="all-and-page"), + pytest.param(("even", 2), id="even-and-even-page"), + pytest.param(("odd", 3), id="odd-and-odd-page"), + pytest.param(("1-3", "3-last"), id="range-and-last-range"), + ], +) +def test_set_page_boxes_payload_rejects_conflicting_selections( + ranges: tuple[str | int, str | int], +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + definition = _media_box(ranges[0]) + definition["pages"] = [ + *definition["pages"], + _media_box(ranges[1])["pages"][0], + ] + + with pytest.raises( + ValidationError, + match="The box page settings conflict with each other", + ): + PdfSetPageBoxesPayload.model_validate({"file": input_file, "boxes": definition}) + + +def test_set_page_boxes_payload_rejects_conflicts_across_definitions() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + + with pytest.raises( + ValidationError, + match=r"Page selection \[2-4\] overlaps an earlier media selection", + ): + PdfSetPageBoxesPayload.model_validate( + { + "file": input_file, + "boxes": [_media_box("1-3"), _media_box("2-4")], + } + ) + + +@pytest.mark.parametrize( + "ranges", + [ + pytest.param(("odd", "even"), id="odd-and-even"), + pytest.param(("1-3", "4-last"), id="disjoint-ranges"), + pytest.param(("even", 1), id="even-and-odd-page"), + ], +) +def test_set_page_boxes_payload_accepts_disjoint_selections( + ranges: tuple[str | int, str | int], +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + definition = _media_box(ranges[0]) + definition["pages"] = [ + *definition["pages"], + _media_box(ranges[1])["pages"][0], + ] + + payload = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": definition} + ) + + assert len(payload.boxes[0].pages) == 2 + + +def test_set_page_boxes_payload_allows_same_selection_for_different_boxes() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + + payload = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": [_media_box("all"), _inset_box("crop", "all")]} + ) + + assert len(payload.boxes) == 2 + + +@pytest.mark.parametrize( + "box", + [ + pytest.param("crop", id="crop"), + pytest.param("bleed", id="bleed"), + pytest.param("trim", id="trim"), + pytest.param("art", id="art"), + ], +) +def test_set_pdf_page_boxes_supports_each_inset_box(box: InsetBoxType) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + definition = _inset_box(box) + expected = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": definition} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + with PdfRestClient( + api_key=VALID_API_KEY, + transport=_response_handler(input_file, output_id, expected), + ) as client: + response = client.set_pdf_page_boxes(input_file, boxes=definition) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.input_id == input_file.id + assert response.output_file.type == "application/pdf" + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "box", + [ + pytest.param("crop", id="crop"), + pytest.param("bleed", id="bleed"), + pytest.param("trim", id="trim"), + pytest.param("art", id="art"), + ], +) +async def test_async_set_pdf_page_boxes_supports_each_inset_box( + box: InsetBoxType, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + definition = _inset_box(box) + expected = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": definition} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=_response_handler(input_file, output_id, expected), + ) as client: + response = await client.set_pdf_page_boxes(input_file, boxes=definition) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.input_id == input_file.id + assert response.output_file.type == "application/pdf" + assert response.output_file.size > 0 + assert response.output_file.url + assert response.warning is None + + +@pytest.mark.parametrize( + ("margin", "match"), + [ + pytest.param(0, "greater than 0", id="zero"), + pytest.param(-0.1, "greater than 0", id="negative"), + pytest.param(float("inf"), "finite number", id="infinite"), + pytest.param(float("nan"), "finite number", id="nan"), + ], +) +@pytest.mark.parametrize( + "field", + [ + pytest.param("top", id="top"), + pytest.param("bottom", id="bottom"), + pytest.param("left", id="left"), + pytest.param("right", id="right"), + ], +) +def test_set_page_boxes_payload_rejects_invalid_inset_margins( + margin: float, + match: str, + field: MarginField, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + box = _inset_box() + box["pages"][0][field] = margin + + with pytest.raises(ValidationError, match=match): + PdfSetPageBoxesPayload.model_validate({"file": input_file, "boxes": box}) + + +@pytest.mark.parametrize( + "field", + [ + pytest.param("top", id="top"), + pytest.param("bottom", id="bottom"), + pytest.param("left", id="left"), + pytest.param("right", id="right"), + ], +) +def test_set_page_boxes_payload_accepts_positive_inset_boundary( + field: MarginField, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + box = _inset_box() + box["pages"][0][field] = 0.1 + + payload = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": box} + ).model_dump(mode="json", by_alias=True) + + page = json.loads(payload["boxes"])["boxes"][0]["pages"][0] + assert page[field] == pytest.approx(0.1) + + +@pytest.mark.parametrize( + "field", + [ + pytest.param("top", id="top"), + pytest.param("bottom", id="bottom"), + pytest.param("left", id="left"), + pytest.param("right", id="right"), + ], +) +def test_set_pdf_page_boxes_sends_positive_inset_boundary( + field: MarginField, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + definition = _inset_box() + definition["pages"][0][field] = 0.1 + expected = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": definition} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + with PdfRestClient( + api_key=VALID_API_KEY, + transport=_response_handler(input_file, output_id, expected), + ) as client: + response = client.set_pdf_page_boxes(input_file, boxes=definition) + + assert response.input_id == input_file.id + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "field", + [ + pytest.param("top", id="top"), + pytest.param("bottom", id="bottom"), + pytest.param("left", id="left"), + pytest.param("right", id="right"), + ], +) +async def test_async_set_pdf_page_boxes_sends_positive_inset_boundary( + field: MarginField, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + definition = _inset_box() + definition["pages"][0][field] = 0.1 + expected = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": definition} + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=_response_handler(input_file, output_id, expected), + ) as client: + response = await client.set_pdf_page_boxes(input_file, boxes=definition) + + assert response.input_id == input_file.id + + +@pytest.mark.parametrize( + "field", + [ + pytest.param("top", id="top"), + pytest.param("bottom", id="bottom"), + pytest.param("left", id="left"), + pytest.param("right", id="right"), + ], +) +@pytest.mark.parametrize( + "margin", + [ + pytest.param(0, id="zero"), + pytest.param(-1, id="negative"), + ], +) +def test_set_pdf_page_boxes_rejects_invalid_inset_margin_before_transport( + field: MarginField, + margin: int, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + definition = _inset_box() + definition["pages"][0][field] = margin + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(lambda _: pytest.fail("request was sent")), + ) as client, + pytest.raises(ValidationError, match=rf"{field}[\s\S]*greater than 0"), + ): + client.set_pdf_page_boxes(input_file, boxes=definition) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "field", + [ + pytest.param("top", id="top"), + pytest.param("bottom", id="bottom"), + pytest.param("left", id="left"), + pytest.param("right", id="right"), + ], +) +@pytest.mark.parametrize( + "margin", + [ + pytest.param(0, id="zero"), + pytest.param(-1, id="negative"), + ], +) +async def test_async_set_pdf_page_boxes_rejects_invalid_inset_margin_before_transport( + field: MarginField, + margin: int, +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + definition = _inset_box() + definition["pages"][0][field] = margin + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(lambda _: pytest.fail("request was sent")), + ) as client: + with pytest.raises(ValidationError, match=rf"{field}[\s\S]*greater than 0"): + await client.set_pdf_page_boxes(input_file, boxes=definition) + + +def test_set_page_boxes_payload_accepts_signed_media_margins() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + + payload = PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": _media_box()} + ).model_dump(mode="json", by_alias=True) + + page = json.loads(payload["boxes"])["boxes"][0]["pages"][0] + assert page["top"] == -18 + assert page["bottom"] == 0 + + +@pytest.mark.parametrize( + ("case", "match"), + [ + pytest.param("empty", "at least 1 item", id="empty-boxes"), + pytest.param( + "descending", + r"The given page range is invalid\. \[4\-2\]\.", + id="descending-range", + ), + pytest.param( + "overlap", + "The box page settings conflict with each other", + id="overlapping-ranges", + ), + ], +) +def test_set_pdf_page_boxes_rejects_invalid_definitions_before_transport( + case: str, match: str +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + boxes: list[PdfPageBoxDefinition] + if case == "empty": + boxes = [] + elif case == "descending": + boxes = [_media_box("4-2")] + else: + definition = _media_box("1-3") + definition["pages"] = [ + *definition["pages"], + _media_box("2-4")["pages"][0], + ] + boxes = [definition] + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(lambda _: pytest.fail("request was sent")), + ) as client, + pytest.raises(ValidationError, match=match), + ): + client.set_pdf_page_boxes(input_file, boxes=boxes) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("case", "match"), + [ + pytest.param("empty", "at least 1 item", id="empty-boxes"), + pytest.param( + "descending", + r"The given page range is invalid\. \[4\-2\]\.", + id="descending-range", + ), + pytest.param( + "overlap", + "The box page settings conflict with each other", + id="overlapping-ranges", + ), + ], +) +async def test_async_set_pdf_page_boxes_rejects_invalid_definitions_before_transport( + case: str, match: str +) -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + boxes: list[PdfPageBoxDefinition] + if case == "empty": + boxes = [] + elif case == "descending": + boxes = [_media_box("4-2")] + else: + definition = _media_box("1-3") + definition["pages"] = [ + *definition["pages"], + _media_box("2-4")["pages"][0], + ] + boxes = [definition] + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(lambda _: pytest.fail("request was sent")), + ) as client: + with pytest.raises(ValidationError, match=match): + await client.set_pdf_page_boxes(input_file, boxes=boxes) + + +def test_set_pdf_page_boxes_request_customization() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + captured_timeout: float | dict[str, float] | None = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal captured_timeout + assert request.url.params["trace"] == "sync" + assert request.headers["X-Debug"] == "page-boxes" + if request.method == "POST": + captured_timeout = request.extensions.get("timeout") + assert json.loads(request.content) == { + "id": str(input_file.id), + "boxes": PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": _media_box()} + ).model_dump(mode="json", by_alias=True)["boxes"], + "output": "boxed-sync", + "debug": True, + } + return httpx.Response( + 200, json={"inputId": [input_file.id], "outputId": [output_id]} + ) + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "boxed-sync.pdf", "application/pdf" + ), + ) + + with PdfRestClient( + api_key=VALID_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = client.set_pdf_page_boxes( + input_file, + boxes=_media_box(), + output="boxed-sync", + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "page-boxes"}, + extra_body={"debug": True}, + timeout=0.71, + ) + + assert response.output_file.name == "boxed-sync.pdf" + _assert_timeout(captured_timeout, 0.71) + + +@pytest.mark.asyncio +async def test_async_set_pdf_page_boxes_request_customization() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + output_id = str(PdfRestFileID.generate()) + captured_timeout: float | dict[str, float] | None = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal captured_timeout + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "page-boxes" + if request.method == "POST": + captured_timeout = request.extensions.get("timeout") + assert json.loads(request.content) == { + "id": str(input_file.id), + "boxes": PdfSetPageBoxesPayload.model_validate( + {"file": input_file, "boxes": _media_box()} + ).model_dump(mode="json", by_alias=True)["boxes"], + "output": "boxed-async", + "debug": True, + } + return httpx.Response( + 200, json={"inputId": [input_file.id], "outputId": [output_id]} + ) + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "boxed-async.pdf", "application/pdf" + ), + ) + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, transport=httpx.MockTransport(handler) + ) as client: + response = await client.set_pdf_page_boxes( + input_file, + boxes=_media_box(), + output="boxed-async", + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "page-boxes"}, + extra_body={"debug": True}, + timeout=0.83, + ) + + assert response.output_file.name == "boxed-async.pdf" + _assert_timeout(captured_timeout, 0.83) + + +@pytest.mark.parametrize( + ("files", "match"), + [ + pytest.param([], "at least 1 item", id="empty"), + pytest.param("png", "Must be a PDF file", id="mime"), + pytest.param("multiple", "at most 1 item", id="cardinality"), + ], +) +def test_set_pdf_page_boxes_rejects_invalid_resources( + files: list[PdfRestFile] | str, match: str +) -> None: + pdf = make_pdf_file(PdfRestFileID.generate()) + file_argument: PdfRestFile | list[PdfRestFile] + if files == "png": + file_argument = PdfRestFile.model_validate( + build_file_info_payload(PdfRestFileID.generate(), "image.png", "image/png") + ) + elif files == "multiple": + file_argument = [pdf, make_pdf_file(PdfRestFileID.generate())] + else: + file_argument = [] + + with ( + PdfRestClient( + api_key=VALID_API_KEY, + transport=httpx.MockTransport(lambda _: pytest.fail("request was sent")), + ) as client, + pytest.raises(ValidationError, match=match), + ): + client.set_pdf_page_boxes(file_argument, boxes=_media_box()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("files", "match"), + [ + pytest.param([], "at least 1 item", id="empty"), + pytest.param("png", "Must be a PDF file", id="mime"), + pytest.param("multiple", "at most 1 item", id="cardinality"), + ], +) +async def test_async_set_pdf_page_boxes_rejects_invalid_resources( + files: list[PdfRestFile] | str, match: str +) -> None: + pdf = make_pdf_file(PdfRestFileID.generate()) + file_argument: PdfRestFile | list[PdfRestFile] + if files == "png": + file_argument = PdfRestFile.model_validate( + build_file_info_payload(PdfRestFileID.generate(), "image.png", "image/png") + ) + elif files == "multiple": + file_argument = [pdf, make_pdf_file(PdfRestFileID.generate())] + else: + file_argument = [] + + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=httpx.MockTransport(lambda _: pytest.fail("request was sent")), + ) as client: + with pytest.raises(ValidationError, match=match): + await client.set_pdf_page_boxes(file_argument, boxes=_media_box())