Skip to content

Commit 0c2d0f5

Browse files
Add Watermark PDF tool
Assisted-by: Codex
1 parent 3d1c459 commit 0c2d0f5

4 files changed

Lines changed: 238 additions & 0 deletions

File tree

src/pdfrest/client.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
PdfToPowerpointPayload,
104104
PdfToWordPayload,
105105
PdfUnrestrictPayload,
106+
PdfWatermarkPayload,
106107
PdfXfaToAcroformsPayload,
107108
PngPdfRestPayload,
108109
SummarizePdfTextPayload,
@@ -121,6 +122,7 @@
121122
JpegColorModel,
122123
OcrLanguage,
123124
PdfAType,
125+
PdfCMYKColor,
124126
PdfInfoQuery,
125127
PdfMergeInput,
126128
PdfPageSelection,
@@ -133,6 +135,8 @@
133135
SummaryOutputFormat,
134136
TiffColorModel,
135137
TranslateOutputFormat,
138+
WatermarkHorizontalAlignment,
139+
WatermarkVerticalAlignment,
136140
)
137141

138142
__all__ = ("AsyncPdfRestClient", "PdfRestClient")
@@ -3078,6 +3082,65 @@ def rasterize_pdf(
30783082
timeout=timeout,
30793083
)
30803084

3085+
def watermark_pdf(
3086+
self,
3087+
file: PdfRestFile | Sequence[PdfRestFile],
3088+
*,
3089+
watermark_text: str | None = None,
3090+
watermark_file: PdfRestFile | Sequence[PdfRestFile] | None = None,
3091+
output: str | None = None,
3092+
font: str | None = None,
3093+
text_size: int = 72,
3094+
text_color_rgb: PdfRGBColor | Sequence[int] | None = None,
3095+
text_color_cmyk: PdfCMYKColor | Sequence[int] | None = None,
3096+
watermark_file_scale: float = 0.5,
3097+
opacity: float = 0.5,
3098+
horizontal_alignment: WatermarkHorizontalAlignment = "center",
3099+
vertical_alignment: WatermarkVerticalAlignment = "center",
3100+
x: int = 0,
3101+
y: int = 0,
3102+
rotation: int = 0,
3103+
pages: PdfPageSelection | None = None,
3104+
behind_page: bool = False,
3105+
extra_query: Query | None = None,
3106+
extra_headers: AnyMapping | None = None,
3107+
extra_body: Body | None = None,
3108+
timeout: TimeoutTypes | None = None,
3109+
) -> PdfRestFileBasedResponse:
3110+
"""Apply a text or file watermark to a PDF."""
3111+
3112+
payload: dict[str, Any] = {
3113+
"files": file,
3114+
"watermark_text": watermark_text,
3115+
"watermark_file": watermark_file,
3116+
"font": font,
3117+
"text_size": text_size,
3118+
"text_color_rgb": text_color_rgb,
3119+
"text_color_cmyk": text_color_cmyk,
3120+
"watermark_file_scale": watermark_file_scale,
3121+
"opacity": opacity,
3122+
"horizontal_alignment": horizontal_alignment,
3123+
"vertical_alignment": vertical_alignment,
3124+
"x": x,
3125+
"y": y,
3126+
"rotation": rotation,
3127+
"behind_page": behind_page,
3128+
}
3129+
if output is not None:
3130+
payload["output"] = output
3131+
if pages is not None:
3132+
payload["pages"] = pages
3133+
3134+
return self._post_file_operation(
3135+
endpoint="/watermarked-pdf",
3136+
payload=payload,
3137+
payload_model=PdfWatermarkPayload,
3138+
extra_query=extra_query,
3139+
extra_headers=extra_headers,
3140+
extra_body=extra_body,
3141+
timeout=timeout,
3142+
)
3143+
30813144
def convert_to_pdfa(
30823145
self,
30833146
file: PdfRestFile | Sequence[PdfRestFile],
@@ -4365,6 +4428,65 @@ async def rasterize_pdf(
43654428
timeout=timeout,
43664429
)
43674430

4431+
async def watermark_pdf(
4432+
self,
4433+
file: PdfRestFile | Sequence[PdfRestFile],
4434+
*,
4435+
watermark_text: str | None = None,
4436+
watermark_file: PdfRestFile | Sequence[PdfRestFile] | None = None,
4437+
output: str | None = None,
4438+
font: str | None = None,
4439+
text_size: int = 72,
4440+
text_color_rgb: PdfRGBColor | Sequence[int] | None = None,
4441+
text_color_cmyk: PdfCMYKColor | Sequence[int] | None = None,
4442+
watermark_file_scale: float = 0.5,
4443+
opacity: float = 0.5,
4444+
horizontal_alignment: WatermarkHorizontalAlignment = "center",
4445+
vertical_alignment: WatermarkVerticalAlignment = "center",
4446+
x: int = 0,
4447+
y: int = 0,
4448+
rotation: int = 0,
4449+
pages: PdfPageSelection | None = None,
4450+
behind_page: bool = False,
4451+
extra_query: Query | None = None,
4452+
extra_headers: AnyMapping | None = None,
4453+
extra_body: Body | None = None,
4454+
timeout: TimeoutTypes | None = None,
4455+
) -> PdfRestFileBasedResponse:
4456+
"""Asynchronously apply a text or file watermark to a PDF."""
4457+
4458+
payload: dict[str, Any] = {
4459+
"files": file,
4460+
"watermark_text": watermark_text,
4461+
"watermark_file": watermark_file,
4462+
"font": font,
4463+
"text_size": text_size,
4464+
"text_color_rgb": text_color_rgb,
4465+
"text_color_cmyk": text_color_cmyk,
4466+
"watermark_file_scale": watermark_file_scale,
4467+
"opacity": opacity,
4468+
"horizontal_alignment": horizontal_alignment,
4469+
"vertical_alignment": vertical_alignment,
4470+
"x": x,
4471+
"y": y,
4472+
"rotation": rotation,
4473+
"behind_page": behind_page,
4474+
}
4475+
if output is not None:
4476+
payload["output"] = output
4477+
if pages is not None:
4478+
payload["pages"] = pages
4479+
4480+
return await self._post_file_operation(
4481+
endpoint="/watermarked-pdf",
4482+
payload=payload,
4483+
payload_model=PdfWatermarkPayload,
4484+
extra_query=extra_query,
4485+
extra_headers=extra_headers,
4486+
extra_body=extra_body,
4487+
timeout=timeout,
4488+
)
4489+
43684490
async def convert_to_pdfa(
43694491
self,
43704492
file: PdfRestFile | Sequence[PdfRestFile],

src/pdfrest/models/_internal.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
SummaryOutputFormat,
3333
SummaryOutputType,
3434
TranslateOutputFormat,
35+
WatermarkHorizontalAlignment,
36+
WatermarkVerticalAlignment,
3537
)
3638
from .public import PdfRestFile, PdfRestFileID
3739

@@ -563,6 +565,7 @@ class ExtractImagesPayload(BaseModel):
563565

564566

565567
RgbChannel = Annotated[int, Field(ge=0, le=255)]
568+
CmykChannel = Annotated[int, Field(ge=0, le=100)]
566569

567570

568571
class PdfLiteralRedactionModel(BaseModel):
@@ -1021,6 +1024,107 @@ def _validate_profile_dependency(self) -> PdfCompressPayload:
10211024
return self
10221025

10231026

1027+
class PdfWatermarkPayload(BaseModel):
1028+
"""Adapt caller options into a pdfRest-ready watermark request payload."""
1029+
1030+
files: Annotated[
1031+
list[PdfRestFile],
1032+
Field(
1033+
min_length=1,
1034+
max_length=1,
1035+
validation_alias=AliasChoices("file", "files"),
1036+
serialization_alias="id",
1037+
),
1038+
BeforeValidator(_ensure_list),
1039+
AfterValidator(
1040+
_allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
1041+
),
1042+
PlainSerializer(_serialize_as_first_file_id),
1043+
]
1044+
watermark_text: Annotated[
1045+
str | None,
1046+
Field(serialization_alias="watermark_text", min_length=1, default=None),
1047+
] = None
1048+
watermark_file: Annotated[
1049+
list[PdfRestFile] | None,
1050+
Field(
1051+
default=None,
1052+
min_length=1,
1053+
max_length=1,
1054+
serialization_alias="watermark_file_id",
1055+
),
1056+
BeforeValidator(_ensure_list),
1057+
AfterValidator(
1058+
_allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
1059+
),
1060+
PlainSerializer(_serialize_as_first_file_id),
1061+
] = None
1062+
output: Annotated[
1063+
str | None,
1064+
Field(serialization_alias="output", min_length=1, default=None),
1065+
AfterValidator(_validate_output_prefix),
1066+
] = None
1067+
font: Annotated[
1068+
str | None, Field(serialization_alias="font", min_length=1, default=None)
1069+
] = None
1070+
text_size: Annotated[
1071+
int,
1072+
Field(serialization_alias="text_size", ge=5, le=100, default=72),
1073+
] = 72
1074+
text_color_rgb: Annotated[
1075+
tuple[RgbChannel, RgbChannel, RgbChannel] | None,
1076+
Field(serialization_alias="text_color_rgb", default=None),
1077+
BeforeValidator(_split_comma_string),
1078+
PlainSerializer(_serialize_as_comma_separated_string),
1079+
] = None
1080+
text_color_cmyk: Annotated[
1081+
tuple[CmykChannel, CmykChannel, CmykChannel, CmykChannel] | None,
1082+
Field(serialization_alias="text_color_cmyk", default=None),
1083+
BeforeValidator(_split_comma_string),
1084+
PlainSerializer(_serialize_as_comma_separated_string),
1085+
] = None
1086+
watermark_file_scale: Annotated[
1087+
float, Field(serialization_alias="watermark_file_scale", ge=0, default=0.5)
1088+
] = 0.5
1089+
opacity: Annotated[
1090+
float, Field(serialization_alias="opacity", ge=0, le=1, default=0.5)
1091+
] = 0.5
1092+
horizontal_alignment: Annotated[
1093+
WatermarkHorizontalAlignment,
1094+
Field(serialization_alias="horizontal_alignment", default="center"),
1095+
] = "center"
1096+
vertical_alignment: Annotated[
1097+
WatermarkVerticalAlignment,
1098+
Field(serialization_alias="vertical_alignment", default="center"),
1099+
] = "center"
1100+
x: Annotated[int, Field(serialization_alias="x", default=0)] = 0
1101+
y: Annotated[int, Field(serialization_alias="y", default=0)] = 0
1102+
rotation: Annotated[int, Field(serialization_alias="rotation", default=0)] = 0
1103+
pages: Annotated[
1104+
list[AscendingPageRange] | None,
1105+
Field(serialization_alias="pages", min_length=1, default=None),
1106+
BeforeValidator(_ensure_list),
1107+
BeforeValidator(_split_comma_list),
1108+
BeforeValidator(_int_to_string),
1109+
PlainSerializer(_serialize_page_ranges),
1110+
] = None
1111+
behind_page: Annotated[
1112+
bool, Field(serialization_alias="behind_page", default=False)
1113+
] = False
1114+
1115+
@model_validator(mode="after")
1116+
def _validate_watermark_payload(self) -> PdfWatermarkPayload:
1117+
has_text = self.watermark_text is not None
1118+
has_file = self.watermark_file is not None
1119+
if has_text == has_file:
1120+
msg = "Provide exactly one of watermark_text or watermark_file."
1121+
raise ValueError(msg)
1122+
if self.text_color_rgb is not None and self.text_color_cmyk is not None:
1123+
msg = "Specify only one of text_color_rgb or text_color_cmyk."
1124+
raise ValueError(msg)
1125+
return self
1126+
1127+
10241128
class PdfXfaToAcroformsPayload(BaseModel):
10251129
"""Adapt caller options into a pdfRest-ready XFA-to-AcroForms request payload."""
10261130

src/pdfrest/types/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
JpegColorModel,
1414
OcrLanguage,
1515
PdfAType,
16+
PdfCMYKColor,
1617
PdfInfoQuery,
1718
PdfMergeInput,
1819
PdfMergeSource,
@@ -29,6 +30,8 @@
2930
SummaryOutputType,
3031
TiffColorModel,
3132
TranslateOutputFormat,
33+
WatermarkHorizontalAlignment,
34+
WatermarkVerticalAlignment,
3235
)
3336

3437
__all__ = [
@@ -44,6 +47,7 @@
4447
"JpegColorModel",
4548
"OcrLanguage",
4649
"PdfAType",
50+
"PdfCMYKColor",
4751
"PdfInfoQuery",
4852
"PdfMergeInput",
4953
"PdfMergeSource",
@@ -60,4 +64,6 @@
6064
"SummaryOutputType",
6165
"TiffColorModel",
6266
"TranslateOutputFormat",
67+
"WatermarkHorizontalAlignment",
68+
"WatermarkVerticalAlignment",
6369
]

src/pdfrest/types/public.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"JpegColorModel",
2626
"OcrLanguage",
2727
"PdfAType",
28+
"PdfCMYKColor",
2829
"PdfInfoQuery",
2930
"PdfMergeInput",
3031
"PdfMergeSource",
@@ -41,6 +42,8 @@
4142
"SummaryOutputType",
4243
"TiffColorModel",
4344
"TranslateOutputFormat",
45+
"WatermarkHorizontalAlignment",
46+
"WatermarkVerticalAlignment",
4447
)
4548

4649
PdfInfoQuery = Literal[
@@ -104,6 +107,7 @@ class PdfRedactionInstruction(TypedDict):
104107
value: PdfRedactionPreset | str
105108

106109

110+
PdfCMYKColor = tuple[int, int, int, int]
107111
PdfRGBColor = tuple[int, int, int]
108112

109113
PdfPageSelection = str | int | Sequence[str | int]
@@ -177,3 +181,5 @@ class PdfMergeSource(TypedDict, total=False):
177181
ALL_PDF_RESTRICTIONS: tuple[PdfRestriction, ...] = cast(
178182
tuple[PdfRestriction, ...], get_args(PdfRestriction)
179183
)
184+
WatermarkHorizontalAlignment = Literal["left", "center", "right"]
185+
WatermarkVerticalAlignment = Literal["top", "center", "bottom"]

0 commit comments

Comments
 (0)