Skip to content

Commit 7c6dcbb

Browse files
Add Blank PDF methods
- Add `blank_pdf()` sync and async methods - Ensure compatibility with Blank PDF response: - Allow PdfRestRawFileResponse.input_id to default empty so missing inputId doesn’t fail validation - When normalizing file responses, fall back to raw ids (outputId) when inputId is absent for blank-pdf - Document blank-pdf handling to keep response construction working without server-provided input ids Assisted-by: Codex
1 parent 20bce70 commit 7c6dcbb

6 files changed

Lines changed: 565 additions & 3 deletions

File tree

src/pdfrest/client.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
GifPdfRestPayload,
8282
JpegPdfRestPayload,
8383
OcrPdfPayload,
84+
PdfBlankPayload,
8485
PdfCompressPayload,
8586
PdfFlattenAnnotationsPayload,
8687
PdfFlattenFormsPayload,
@@ -118,7 +119,9 @@
118119
PdfAType,
119120
PdfInfoQuery,
120121
PdfMergeInput,
122+
PdfPageOrientation,
121123
PdfPageSelection,
124+
PdfPageSize,
122125
PdfRedactionInstruction,
123126
PdfRGBColor,
124127
PdfXType,
@@ -1029,8 +1032,9 @@ def _post_file_operation(
10291032
for file_id in output_ids
10301033
]
10311034

1035+
input_ids = raw_response.input_id or (raw_response.ids or [])
10321036
response_payload: dict[str, Any] = {
1033-
"input_id": [str(file_id) for file_id in raw_response.input_id],
1037+
"input_id": [str(file_id) for file_id in input_ids],
10341038
"output_file": [
10351039
file.model_dump(mode="json", by_alias=True) for file in output_files
10361040
],
@@ -1304,8 +1308,9 @@ async def throttled_fetch_file_info(file_id: str) -> PdfRestFile:
13041308
)
13051309
)
13061310

1311+
input_ids = raw_response.input_id or (raw_response.ids or [])
13071312
response_payload: dict[str, Any] = {
1308-
"input_id": [str(file_id) for file_id in raw_response.input_id],
1313+
"input_id": [str(file_id) for file_id in input_ids],
13091314
"output_file": [
13101315
file.model_dump(mode="json", by_alias=True) for file in output_files
13111316
],
@@ -2717,6 +2722,45 @@ def compress_pdf(
27172722
timeout=timeout,
27182723
)
27192724

2725+
def blank_pdf(
2726+
self,
2727+
*,
2728+
page_size: PdfPageSize,
2729+
page_count: int,
2730+
page_orientation: PdfPageOrientation | None = None,
2731+
custom_height: float | None = None,
2732+
custom_width: float | None = None,
2733+
output: str | None = None,
2734+
extra_query: Query | None = None,
2735+
extra_headers: AnyMapping | None = None,
2736+
extra_body: Body | None = None,
2737+
timeout: TimeoutTypes | None = None,
2738+
) -> PdfRestFileBasedResponse:
2739+
"""Create a blank PDF with the specified size, count, and orientation."""
2740+
2741+
payload: dict[str, Any] = {
2742+
"page_size": page_size,
2743+
"page_count": page_count,
2744+
}
2745+
if page_orientation is not None:
2746+
payload["page_orientation"] = page_orientation
2747+
if custom_height is not None:
2748+
payload["custom_height"] = custom_height
2749+
if custom_width is not None:
2750+
payload["custom_width"] = custom_width
2751+
if output is not None:
2752+
payload["output"] = output
2753+
2754+
return self._post_file_operation(
2755+
endpoint="/blank-pdf",
2756+
payload=payload,
2757+
payload_model=PdfBlankPayload,
2758+
extra_query=extra_query,
2759+
extra_headers=extra_headers,
2760+
extra_body=extra_body,
2761+
timeout=timeout,
2762+
)
2763+
27202764
def flatten_transparencies(
27212765
self,
27222766
file: PdfRestFile | Sequence[PdfRestFile],
@@ -3754,6 +3798,45 @@ async def compress_pdf(
37543798
timeout=timeout,
37553799
)
37563800

3801+
async def blank_pdf(
3802+
self,
3803+
*,
3804+
page_size: PdfPageSize,
3805+
page_count: int,
3806+
page_orientation: PdfPageOrientation | None = None,
3807+
custom_height: float | None = None,
3808+
custom_width: float | None = None,
3809+
output: str | None = None,
3810+
extra_query: Query | None = None,
3811+
extra_headers: AnyMapping | None = None,
3812+
extra_body: Body | None = None,
3813+
timeout: TimeoutTypes | None = None,
3814+
) -> PdfRestFileBasedResponse:
3815+
"""Asynchronously create a blank PDF with the specified size."""
3816+
3817+
payload: dict[str, Any] = {
3818+
"page_size": page_size,
3819+
"page_count": page_count,
3820+
}
3821+
if page_orientation is not None:
3822+
payload["page_orientation"] = page_orientation
3823+
if custom_height is not None:
3824+
payload["custom_height"] = custom_height
3825+
if custom_width is not None:
3826+
payload["custom_width"] = custom_width
3827+
if output is not None:
3828+
payload["output"] = output
3829+
3830+
return await self._post_file_operation(
3831+
endpoint="/blank-pdf",
3832+
payload=payload,
3833+
payload_model=PdfBlankPayload,
3834+
extra_query=extra_query,
3835+
extra_headers=extra_headers,
3836+
extra_body=extra_body,
3837+
timeout=timeout,
3838+
)
3839+
37573840
async def flatten_transparencies(
37583841
self,
37593842
file: PdfRestFile | Sequence[PdfRestFile],

src/pdfrest/models/_internal.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
OcrLanguage,
2727
PdfAType,
2828
PdfInfoQuery,
29+
PdfPageOrientation,
30+
PdfPageSize,
2931
PdfXType,
3032
SummaryFormat,
3133
SummaryOutputFormat,
@@ -1142,6 +1144,57 @@ class PdfFlattenAnnotationsPayload(BaseModel):
11421144
] = None
11431145

11441146

1147+
class PdfBlankPayload(BaseModel):
1148+
"""Adapt caller options into a pdfRest-ready blank PDF request payload."""
1149+
1150+
page_size: Annotated[
1151+
PdfPageSize,
1152+
Field(serialization_alias="page_size"),
1153+
]
1154+
page_count: Annotated[
1155+
int,
1156+
Field(serialization_alias="page_count", ge=1, le=1000),
1157+
]
1158+
page_orientation: Annotated[
1159+
PdfPageOrientation | None,
1160+
Field(serialization_alias="page_orientation", default=None),
1161+
] = None
1162+
custom_height: Annotated[
1163+
float | None,
1164+
Field(serialization_alias="custom_height", gt=0, default=None),
1165+
] = None
1166+
custom_width: Annotated[
1167+
float | None,
1168+
Field(serialization_alias="custom_width", gt=0, default=None),
1169+
] = None
1170+
output: Annotated[
1171+
str | None,
1172+
Field(serialization_alias="output", min_length=1, default=None),
1173+
AfterValidator(_validate_output_prefix),
1174+
] = None
1175+
1176+
@model_validator(mode="after")
1177+
def _validate_page_configuration(self) -> PdfBlankPayload:
1178+
is_custom = self.page_size == "custom"
1179+
has_custom_height = self.custom_height is not None
1180+
has_custom_width = self.custom_width is not None
1181+
if is_custom:
1182+
if not (has_custom_height and has_custom_width):
1183+
msg = "custom_height and custom_width are required when page_size is 'custom'."
1184+
raise ValueError(msg)
1185+
if self.page_orientation is not None:
1186+
msg = "page_orientation must be omitted when page_size is 'custom'."
1187+
raise ValueError(msg)
1188+
else:
1189+
if self.page_orientation is None:
1190+
msg = "page_orientation is required when page_size is not 'custom'."
1191+
raise ValueError(msg)
1192+
if has_custom_height or has_custom_width:
1193+
msg = "custom_height and custom_width can only be provided when page_size is 'custom'."
1194+
raise ValueError(msg)
1195+
return self
1196+
1197+
11451198
class BmpPdfRestPayload(BasePdfRestGraphicPayload[Literal["rgb", "gray"]]):
11461199
"""Adapt caller options into a pdfRest-ready BMP request payload."""
11471200

@@ -1193,7 +1246,11 @@ class PdfRestRawFileResponse(BaseModel):
11931246

11941247
input_id: Annotated[
11951248
list[PdfRestFileID],
1196-
Field(alias="inputId", description="The id of the input file"),
1249+
Field(
1250+
alias="inputId",
1251+
description="The id of the input file",
1252+
default_factory=list,
1253+
),
11971254
BeforeValidator(_ensure_list),
11981255
]
11991256
output_urls: Annotated[

src/pdfrest/types/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
PdfInfoQuery,
1616
PdfMergeInput,
1717
PdfMergeSource,
18+
PdfPageOrientation,
1819
PdfPageSelection,
20+
PdfPageSize,
1921
PdfRedactionInstruction,
2022
PdfRedactionPreset,
2123
PdfRedactionType,
@@ -44,7 +46,9 @@
4446
"PdfInfoQuery",
4547
"PdfMergeInput",
4648
"PdfMergeSource",
49+
"PdfPageOrientation",
4750
"PdfPageSelection",
51+
"PdfPageSize",
4852
"PdfRGBColor",
4953
"PdfRedactionInstruction",
5054
"PdfRedactionPreset",

src/pdfrest/types/public.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
"PdfInfoQuery",
2828
"PdfMergeInput",
2929
"PdfMergeSource",
30+
"PdfPageOrientation",
3031
"PdfPageSelection",
32+
"PdfPageSize",
3133
"PdfRGBColor",
3234
"PdfRedactionInstruction",
3335
"PdfRedactionPreset",
@@ -160,3 +162,5 @@ class PdfMergeSource(TypedDict, total=False):
160162
ALL_OCR_LANGUAGES: tuple[OcrLanguage, ...] = cast(
161163
tuple[OcrLanguage, ...], get_args(OcrLanguage)
162164
)
165+
PdfPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5", "custom"]
166+
PdfPageOrientation = Literal["portrait", "landscape"]

tests/live/test_live_blank_pdf.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient
6+
7+
8+
@pytest.mark.parametrize(
9+
"output_name",
10+
[
11+
pytest.param(None, id="default-output"),
12+
pytest.param("blank-doc", id="custom-output"),
13+
],
14+
)
15+
def test_live_blank_pdf_success(
16+
pdfrest_api_key: str,
17+
pdfrest_live_base_url: str,
18+
output_name: str | None,
19+
) -> None:
20+
kwargs: dict[str, str | int] = {
21+
"page_size": "letter",
22+
"page_count": 1,
23+
"page_orientation": "portrait",
24+
}
25+
if output_name is not None:
26+
kwargs["output"] = output_name
27+
28+
with PdfRestClient(
29+
api_key=pdfrest_api_key,
30+
base_url=pdfrest_live_base_url,
31+
) as client:
32+
response = client.blank_pdf(**kwargs)
33+
34+
assert response.output_files
35+
output_file = response.output_file
36+
assert output_file.type == "application/pdf"
37+
assert output_file.size > 0
38+
assert response.warning is None
39+
if output_name is not None:
40+
assert output_file.name.startswith(output_name)
41+
else:
42+
assert output_file.name.endswith(".pdf")
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_live_async_blank_pdf_success(
47+
pdfrest_api_key: str,
48+
pdfrest_live_base_url: str,
49+
) -> None:
50+
async with AsyncPdfRestClient(
51+
api_key=pdfrest_api_key,
52+
base_url=pdfrest_live_base_url,
53+
) as client:
54+
response = await client.blank_pdf(
55+
page_size="A4",
56+
page_count=2,
57+
page_orientation="landscape",
58+
output="async-blank",
59+
)
60+
61+
assert response.output_files
62+
output_file = response.output_file
63+
assert output_file.name.startswith("async-blank")
64+
assert output_file.type == "application/pdf"
65+
assert output_file.size > 0
66+
assert response.warning is None
67+
68+
69+
def test_live_blank_pdf_invalid_request(
70+
pdfrest_api_key: str,
71+
pdfrest_live_base_url: str,
72+
) -> None:
73+
with (
74+
PdfRestClient(
75+
api_key=pdfrest_api_key,
76+
base_url=pdfrest_live_base_url,
77+
) as client,
78+
pytest.raises(PdfRestApiError, match=r"(?i)(page|size)"),
79+
):
80+
client.blank_pdf(
81+
page_size="letter",
82+
page_count=1,
83+
page_orientation="portrait",
84+
extra_body={"page_size": "not-a-size"},
85+
)
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_live_async_blank_pdf_invalid_request(
90+
pdfrest_api_key: str,
91+
pdfrest_live_base_url: str,
92+
) -> None:
93+
async with AsyncPdfRestClient(
94+
api_key=pdfrest_api_key,
95+
base_url=pdfrest_live_base_url,
96+
) as client:
97+
with pytest.raises(PdfRestApiError, match=r"(?i)(page|size)"):
98+
await client.blank_pdf(
99+
page_size="letter",
100+
page_count=1,
101+
page_orientation="portrait",
102+
extra_body={"page_size": "bad-size"},
103+
)

0 commit comments

Comments
 (0)