Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/pdfrest/models/_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This changes local payload validation/serialization, but the branch only adds live coverage. Please add sync and async MockTransport tests, or payload-model tests plus client assertions, showing lowercase output_type normalizes to canonical PDF/A-2b in the outbound /pdfa body.

]
output: Annotated[
str | None,
Field(serialization_alias="output", min_length=1, default=None),
Expand Down
8 changes: 5 additions & 3 deletions src/pdfrest/types/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -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].
"""

Expand All @@ -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].
"""

Expand Down Expand Up @@ -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"]
Expand Down
49 changes: 46 additions & 3 deletions tests/live/test_live_convert_to_pdfa.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import cast, get_args
from typing import Any, cast, get_args

import pytest

Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
67 changes: 66 additions & 1 deletion tests/live/test_live_sign_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)

Expand Down Expand Up @@ -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,
}
Expand All @@ -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,
Expand Down Expand Up @@ -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
78 changes: 78 additions & 0 deletions tests/test_convert_to_pdfa.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
from typing import Any, cast

import httpx
import pytest
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading