diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 85bd11f..50d434e 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -3,7 +3,7 @@ import re from collections.abc import Callable, Mapping, Sequence from pathlib import PurePath -from typing import Annotated, Any, Generic, Literal, TypeVar, cast +from typing import Annotated, Any, Generic, Literal, TypeVar, cast, get_args from langcodes import tag_is_valid from pydantic import ( @@ -49,6 +49,10 @@ from .public import PdfRestFile, PdfRestFileID PdfConvertColorProfile = PdfPresetColorProfile | Literal["custom"] +PDFA_OUTPUT_TYPES: tuple[PdfAType, ...] = cast(tuple[PdfAType, ...], get_args(PdfAType)) +PDFA_OUTPUT_TYPE_MAP: dict[str, PdfAType] = { + output_type.casefold(): output_type for output_type in PDFA_OUTPUT_TYPES +} def _ensure_list(value: Any) -> Any: @@ -188,6 +192,12 @@ def _bool_to_true_false(value: Any) -> Any: return value +def _normalize_pdfa_output_type(value: Any) -> Any: + if not isinstance(value, str): + return value + return PDFA_OUTPUT_TYPE_MAP.get(value.casefold(), value) + + def _serialize_page_ranges(value: list[str | int | tuple[str | int, ...]]) -> str: def join_tuple(value: str | int | tuple[str | int, ...]) -> str: if isinstance(value, tuple): @@ -1011,7 +1021,7 @@ class _PdfSignatureDisplayModel(BaseModel): class _PdfSignatureConfigurationModel(BaseModel): type: Literal["new", "existing"] name: str | None = None - logo_opacity: Annotated[float | None, Field(gt=0, le=1, default=None)] = None + logo_opacity: Annotated[float | None, Field(ge=0, le=1, default=None)] = None location: _PdfSignatureLocationModel | None = None display: _PdfSignatureDisplayModel | None = None @@ -1344,7 +1354,11 @@ class PdfToPdfaPayload(BaseModel): ), PlainSerializer(_serialize_as_first_file_id), ] - output_type: Annotated[PdfAType, Field(serialization_alias="output_type")] + output_type: Annotated[ + PdfAType, + Field(serialization_alias="output_type"), + BeforeValidator(_normalize_pdfa_output_type), + ] output: Annotated[ str | None, Field(serialization_alias="output", min_length=1, default=None), diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 0f15ec2..5b7689e 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -257,7 +257,7 @@ class PdfNewSignatureConfiguration(TypedDict, total=False): type: Must be ``"new"``. location: Placement rectangle and page as [PdfSignatureLocation][pdfrest.types.PdfSignatureLocation]. name: Optional name for the signature field. - logo_opacity: Optional logo opacity in the range ``(0, 1]``. + logo_opacity: Optional logo opacity in the range ``[0, 1]``. display: Optional visible-signature settings as [PdfSignatureDisplay][pdfrest.types.PdfSignatureDisplay]. """ @@ -275,7 +275,7 @@ class PdfExistingSignatureConfiguration(TypedDict, total=False): type: Must be ``"existing"``. location: Optional placement override as [PdfSignatureLocation][pdfrest.types.PdfSignatureLocation]. name: Optional existing signature field name. - logo_opacity: Optional logo opacity in the range ``(0, 1]``. + logo_opacity: Optional logo opacity in the range ``[0, 1]``. display: Optional visible-signature settings as [PdfSignatureDisplay][pdfrest.types.PdfSignatureDisplay]. """ @@ -323,7 +323,9 @@ class PdfPemCredentials(TypedDict): #: [AsyncPdfRestClient.sign_pdf][pdfrest.AsyncPdfRestClient.sign_pdf]. PdfSignatureCredentials = PdfPfxCredentials | PdfPemCredentials -#: PDF/A conformance targets accepted by ``convert_to_pdfa``. +#: Canonical PDF/A conformance targets accepted by ``convert_to_pdfa``. +#: Payload validation accepts case-insensitive string input and normalizes it +#: to one of these literals before serialization. PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"] #: PDF/X conformance targets accepted by ``convert_to_pdfx``. PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] diff --git a/tests/live/test_live_convert_to_pdfa.py b/tests/live/test_live_convert_to_pdfa.py index 8b40221..c15e8f4 100644 --- a/tests/live/test_live_convert_to_pdfa.py +++ b/tests/live/test_live_convert_to_pdfa.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import cast, get_args +from typing import Any, cast, get_args import pytest @@ -101,6 +101,28 @@ def test_live_convert_to_pdfa_with_rasterize_option( assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) +def test_live_convert_to_pdfa_accepts_lowercase_output_type( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type=cast(Any, "pdf/a-2b"), + output="pdfa-lowercase", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("pdfa-lowercase") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) + + @pytest.mark.asyncio async def test_live_async_convert_to_pdfa_with_rasterize_option( pdfrest_api_key: str, @@ -125,12 +147,34 @@ async def test_live_async_convert_to_pdfa_with_rasterize_option( assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) +@pytest.mark.asyncio +async def test_live_async_convert_to_pdfa_accepts_lowercase_output_type( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_pdfa: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.convert_to_pdfa( + uploaded_pdf_for_pdfa, + output_type=cast(Any, "pdf/a-2b"), + output="async-pdfa-lowercase", + ) + + assert response.output_files + output_file = response.output_file + assert output_file.name.startswith("async-pdfa-lowercase") + assert output_file.type == "application/pdf" + assert str(response.input_id) == str(uploaded_pdf_for_pdfa.id) + + @pytest.mark.parametrize( "invalid_output_type", [ pytest.param("PDF/A-0", id="pdfa-0"), pytest.param("PDF/A-99", id="pdfa-99"), - pytest.param("pdf/a-2b", id="lowercase"), ], ) def test_live_convert_to_pdfa_invalid_output_type( @@ -159,7 +203,6 @@ def test_live_convert_to_pdfa_invalid_output_type( [ pytest.param("PDF/A-0", id="pdfa-0"), pytest.param("PDF/A-99", id="pdfa-99"), - pytest.param("pdf/a-2b", id="lowercase"), ], ) async def test_live_async_convert_to_pdfa_invalid_output_type( diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index b852d89..80e8586 100644 --- a/tests/live/test_live_sign_pdf.py +++ b/tests/live/test_live_sign_pdf.py @@ -19,7 +19,7 @@ ) INVALID_LOGO_OPACITY_VALUES = ( - pytest.param(0.0, id="zero"), + pytest.param(-0.1, id="below-min"), pytest.param(1.1, id="above-max"), ) @@ -501,6 +501,7 @@ def test_live_sign_pdf_invalid_logo_opacity( "signature_configuration": _to_json_string( { "type": "new", + "name": "live-invalid-logo-opacity", "location": make_signature_location(), "logo_opacity": invalid_logo_opacity, } @@ -509,6 +510,37 @@ def test_live_sign_pdf_invalid_logo_opacity( ) +def test_live_sign_pdf_logo_opacity_zero_is_allowed( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "name": "live-logo-opacity-zero", + "location": make_signature_location(), + "logo_opacity": 0.0, + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-logo-opacity-zero", + ) + + assert response.output_file.type == "application/pdf" + assert response.output_file.name == "live-logo-opacity-zero.pdf" + assert str(uploaded_pdf_for_signing.id) in response.input_ids + + @pytest.mark.asyncio async def test_live_async_sign_pdf_invalid_signature_configuration( pdfrest_api_key: str, @@ -601,9 +633,42 @@ async def test_live_async_sign_pdf_invalid_logo_opacity( "signature_configuration": _to_json_string( { "type": "new", + "name": "live-async-invalid-logo-opacity", "location": make_signature_location(), "logo_opacity": invalid_logo_opacity, } ) }, ) + + +@pytest.mark.asyncio +async def test_live_async_sign_pdf_logo_opacity_zero_is_allowed( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "name": "live-async-logo-opacity-zero", + "location": make_signature_location(), + "logo_opacity": 0.0, + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-async-logo-opacity-zero", + ) + + assert response.output_file.type == "application/pdf" + assert response.output_file.name == "live-async-logo-opacity-zero.pdf" + assert str(uploaded_pdf_for_signing.id) in response.input_ids diff --git a/tests/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py index 477ec8f..a29e308 100644 --- a/tests/test_convert_to_pdfa.py +++ b/tests/test_convert_to_pdfa.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from typing import Any, cast import httpx import pytest @@ -210,6 +211,44 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.33) +def test_convert_to_pdfa_normalizes_lowercase_output_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdfa": + payload = json.loads(request.content.decode("utf-8")) + assert payload["output_type"] == "PDF/A-2b" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "lowercase.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.convert_to_pdfa( + input_file, + output_type=cast(Any, "pdf/a-2b"), + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "lowercase.pdf" + assert str(response.input_id) == str(input_file.id) + + @pytest.mark.asyncio async def test_async_convert_to_pdfa_request_customization( monkeypatch: pytest.MonkeyPatch, @@ -272,6 +311,45 @@ def handler(request: httpx.Request) -> httpx.Response: assert timeout_value == pytest.approx(0.72) +@pytest.mark.asyncio +async def test_async_convert_to_pdfa_normalizes_lowercase_output_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/pdfa": + payload = json.loads(request.content.decode("utf-8")) + assert payload["output_type"] == "PDF/A-2b" + assert payload["id"] == str(input_file.id) + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "async-lowercase.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.convert_to_pdfa( + input_file, + output_type=cast(Any, "pdf/a-2b"), + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-lowercase.pdf" + assert str(response.input_id) == str(input_file.id) + + def test_convert_to_pdfa_validation(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) pdf_file = make_pdf_file(PdfRestFileID.generate(1)) diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 01bbbec..15a34c6 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -773,6 +773,54 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1, "get": 1} +def test_sign_pdf_allows_zero_logo_opacity_via_public_argument( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/signed-pdf": + payload = json.loads(request.content.decode("utf-8")) + signature_payload = json.loads(payload["signature_configuration"]) + assert signature_payload["logo_opacity"] == pytest.approx(0.0) + assert signature_payload["type"] == "new" + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "logo-opacity-zero.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "name": "visible-zero", + "location": make_signature_location(), + "logo_opacity": 0.0, + }, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "logo-opacity-zero.pdf" + + @pytest.mark.asyncio async def test_async_sign_pdf_request_customization( monkeypatch: pytest.MonkeyPatch, @@ -849,6 +897,55 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen == {"post": 1, "get": 1} +@pytest.mark.asyncio +async def test_async_sign_pdf_allows_zero_logo_opacity_via_public_argument( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/signed-pdf": + payload = json.loads(request.content.decode("utf-8")) + signature_payload = json.loads(payload["signature_configuration"]) + assert signature_payload["logo_opacity"] == pytest.approx(0.0) + assert signature_payload["type"] == "new" + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-logo-opacity-zero.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + response = await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "name": "async-visible-zero", + "location": make_signature_location(), + "logo_opacity": 0.0, + }, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-logo-opacity-zero.pdf" + + def test_sign_payload_requires_location_when_type_new() -> None: input_file = make_pdf_file(PdfRestFileID.generate()) pfx_file = make_pfx_file(str(PdfRestFileID.generate())) @@ -988,6 +1085,7 @@ def test_sign_payload_accepts_logo_tuple_sequence() -> None: @pytest.mark.parametrize( "logo_opacity", [ + pytest.param(0.0, id="zero"), pytest.param(0.01, id="min"), pytest.param(1.0, id="max"), ], @@ -1016,7 +1114,6 @@ def test_sign_payload_accepts_logo_opacity_bounds(logo_opacity: float) -> None: @pytest.mark.parametrize( "invalid_logo_opacity", [ - pytest.param(0.0, id="zero"), pytest.param(-0.01, id="below-min"), pytest.param(1.01, id="above-max"), ], @@ -1030,7 +1127,7 @@ def test_sign_payload_rejects_logo_opacity_out_of_bounds( with pytest.raises( ValidationError, - match=r"greater than 0|less than or equal to 1", + match=r"greater than or equal to 0|less than or equal to 1", ): PdfSignPayload.model_validate( {