From 9e432bc589121debcd22ded04d151590e9d2b629 Mon Sep 17 00:00:00 2001 From: datalogics-cgreen Date: Mon, 1 Jun 2026 16:03:11 -0500 Subject: [PATCH 1/2] pdfa: Permit lowercase PDF/A version names Assisted-by: Codex --- src/pdfrest/client.py | 6 ++-- src/pdfrest/models/_internal.py | 22 +++++++++++- src/pdfrest/types/__init__.py | 2 ++ src/pdfrest/types/public.py | 6 +++- tests/live/test_live_convert_to_pdfa.py | 47 +++++++++++++++++++++++-- tests/test_convert_to_pdfa.py | 4 +-- 6 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 7c66e190..65144579 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -145,7 +145,7 @@ JpegColorModel, OcrLanguage, PdfAddTextObject, - PdfAType, + PdfAOutputType, PdfConversionCompression, PdfConversionDownsample, PdfConversionLocale, @@ -5289,7 +5289,7 @@ def convert_to_pdfa( self, file: PdfRestFile | Sequence[PdfRestFile], *, - output_type: PdfAType, + output_type: PdfAOutputType, output: str | None = None, rasterize_if_errors_encountered: bool = False, extra_query: Query | None = None, @@ -8355,7 +8355,7 @@ async def convert_to_pdfa( self, file: PdfRestFile | Sequence[PdfRestFile], *, - output_type: PdfAType, + output_type: PdfAOutputType, output: str | None = None, rasterize_if_errors_encountered: bool = False, extra_query: Query | None = None, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 85bd11fd..22b5d642 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -49,6 +49,16 @@ from .public import PdfRestFile, PdfRestFileID PdfConvertColorProfile = PdfPresetColorProfile | Literal["custom"] +PDFA_OUTPUT_TYPES: tuple[PdfAType, ...] = ( + "PDF/A-1b", + "PDF/A-2b", + "PDF/A-2u", + "PDF/A-3b", + "PDF/A-3u", +) +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 +198,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): @@ -1344,7 +1360,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/__init__.py b/src/pdfrest/types/__init__.py index f662b0b4..c7b852fd 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -17,6 +17,7 @@ JpegColorModel, OcrLanguage, PdfAddTextObject, + PdfAOutputType, PdfAType, PdfCMYKColor, PdfColorProfile, @@ -69,6 +70,7 @@ "HtmlWebLayout", "JpegColorModel", "OcrLanguage", + "PdfAOutputType", "PdfAType", "PdfAddTextObject", "PdfCMYKColor", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 0f15ec2b..b4d1aed6 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -28,6 +28,7 @@ "HtmlWebLayout", "JpegColorModel", "OcrLanguage", + "PdfAOutputType", "PdfAType", "PdfAddTextObject", "PdfCMYKColor", @@ -323,8 +324,11 @@ 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``. PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"] +#: Caller-facing ``convert_to_pdfa`` input type. Values are matched +#: case-insensitively and normalized to [PdfAType][pdfrest.types.PdfAType]. +PdfAOutputType = PdfAType | str #: PDF/X conformance targets accepted by ``convert_to_pdfx``. PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] #: Granularity modes for extracted full text payloads. diff --git a/tests/live/test_live_convert_to_pdfa.py b/tests/live/test_live_convert_to_pdfa.py index 8b40221d..51651f0d 100644 --- a/tests/live/test_live_convert_to_pdfa.py +++ b/tests/live/test_live_convert_to_pdfa.py @@ -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="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="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/test_convert_to_pdfa.py b/tests/test_convert_to_pdfa.py index 477ec8fa..1b84f461 100644 --- a/tests/test_convert_to_pdfa.py +++ b/tests/test_convert_to_pdfa.py @@ -189,7 +189,7 @@ def handler(request: httpx.Request) -> httpx.Response: with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: response = client.convert_to_pdfa( input_file, - output_type="PDF/A-3b", + output_type="pdf/a-3b", output="custom", rasterize_if_errors_encountered="on", extra_query={"trace": "true"}, @@ -251,7 +251,7 @@ def handler(request: httpx.Request) -> httpx.Response: async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: response = await client.convert_to_pdfa( input_file, - output_type="PDF/A-2u", + output_type="pdf/a-2u", output="async-custom", rasterize_if_errors_encountered="off", extra_query={"trace": "async"}, From a3a16cafac8722f6e3daab2b12176ba7cb17ee8f Mon Sep 17 00:00:00 2001 From: datalogics-cgreen Date: Mon, 1 Jun 2026 16:45:39 -0500 Subject: [PATCH 2/2] signpdf: Disentangle `logo_opacity` tests from unrelated `name` error Assisted-by: Codex --- tests/live/test_live_sign_pdf.py | 85 +++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index b852d89b..6b236695 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,46 @@ 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(), + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + extra_body={ + "signature_configuration": _to_json_string( + { + "type": "new", + "name": "live-logo-opacity-zero", + "location": make_signature_location(), + "logo_opacity": 0.0, + } + ) + }, + 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 +642,51 @@ 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(), + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + extra_body={ + "signature_configuration": _to_json_string( + { + "type": "new", + "name": "live-async-logo-opacity-zero", + "location": make_signature_location(), + "logo_opacity": 0.0, + } + ) + }, + 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