Skip to content

Commit 03ebf29

Browse files
Add Sign PDF tool
Assisted-by: Codex
1 parent 3d1c459 commit 03ebf29

4 files changed

Lines changed: 359 additions & 0 deletions

File tree

src/pdfrest/client.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
PdfRedactionPreviewPayload,
9797
PdfRestRawFileResponse,
9898
PdfRestrictPayload,
99+
PdfSignPayload,
99100
PdfSplitPayload,
100101
PdfToExcelPayload,
101102
PdfToPdfaPayload,
@@ -127,6 +128,8 @@
127128
PdfRedactionInstruction,
128129
PdfRestriction,
129130
PdfRGBColor,
131+
PdfSignatureConfiguration,
132+
PdfSignatureCredentials,
130133
PdfXType,
131134
PngColorModel,
132135
SummaryFormat,
@@ -2973,6 +2976,64 @@ def compress_pdf(
29732976
timeout=timeout,
29742977
)
29752978

2979+
def sign_pdf(
2980+
self,
2981+
file: PdfRestFile | Sequence[PdfRestFile],
2982+
*,
2983+
signature_configuration: PdfSignatureConfiguration,
2984+
credentials: PdfSignatureCredentials,
2985+
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
2986+
output: str | None = None,
2987+
extra_query: Query | None = None,
2988+
extra_headers: AnyMapping | None = None,
2989+
extra_body: Body | None = None,
2990+
timeout: TimeoutTypes | None = None,
2991+
) -> PdfRestFileBasedResponse:
2992+
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""
2993+
2994+
payload: dict[str, Any] = {
2995+
"files": file,
2996+
"signature_configuration": signature_configuration,
2997+
}
2998+
2999+
credential_map = cast(Mapping[str, PdfRestFile], credentials)
3000+
3001+
if "pfx" in credential_map and "certificate" in credential_map:
3002+
msg = "Provide either PFX credentials or certificate/private_key, not both."
3003+
raise PdfRestConfigurationError(msg)
3004+
if "pfx" not in credential_map and "certificate" not in credential_map:
3005+
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
3006+
raise PdfRestConfigurationError(msg)
3007+
if "pfx" in credential_map:
3008+
payload["pfx_credential"] = credential_map["pfx"]
3009+
try:
3010+
payload["pfx_passphrase"] = credential_map["passphrase"]
3011+
except KeyError as exc:
3012+
msg = "PFX credentials require both 'pfx' and 'passphrase' files."
3013+
raise PdfRestConfigurationError(msg) from exc
3014+
elif "certificate" in credential_map:
3015+
payload["certificate"] = credential_map["certificate"]
3016+
try:
3017+
payload["private_key"] = credential_map["private_key"]
3018+
except KeyError as exc:
3019+
msg = "Certificate credentials require both 'certificate' and 'private_key' files."
3020+
raise PdfRestConfigurationError(msg) from exc
3021+
3022+
if logo is not None:
3023+
payload["logo"] = logo
3024+
if output is not None:
3025+
payload["output"] = output
3026+
3027+
return self._post_file_operation(
3028+
endpoint="/signed-pdf",
3029+
payload=payload,
3030+
payload_model=PdfSignPayload,
3031+
extra_query=extra_query,
3032+
extra_headers=extra_headers,
3033+
extra_body=extra_body,
3034+
timeout=timeout,
3035+
)
3036+
29763037
def flatten_transparencies(
29773038
self,
29783039
file: PdfRestFile | Sequence[PdfRestFile],
@@ -4260,6 +4321,64 @@ async def compress_pdf(
42604321
timeout=timeout,
42614322
)
42624323

4324+
async def sign_pdf(
4325+
self,
4326+
file: PdfRestFile | Sequence[PdfRestFile],
4327+
*,
4328+
signature_configuration: PdfSignatureConfiguration,
4329+
credentials: PdfSignatureCredentials,
4330+
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
4331+
output: str | None = None,
4332+
extra_query: Query | None = None,
4333+
extra_headers: AnyMapping | None = None,
4334+
extra_body: Body | None = None,
4335+
timeout: TimeoutTypes | None = None,
4336+
) -> PdfRestFileBasedResponse:
4337+
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""
4338+
4339+
payload: dict[str, Any] = {
4340+
"files": file,
4341+
"signature_configuration": signature_configuration,
4342+
}
4343+
4344+
credential_map = cast(Mapping[str, PdfRestFile], credentials)
4345+
4346+
if "pfx" in credential_map and "certificate" in credential_map:
4347+
msg = "Provide either PFX credentials or certificate/private_key, not both."
4348+
raise PdfRestConfigurationError(msg)
4349+
if "pfx" not in credential_map and "certificate" not in credential_map:
4350+
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
4351+
raise PdfRestConfigurationError(msg)
4352+
if "pfx" in credential_map:
4353+
payload["pfx_credential"] = credential_map["pfx"]
4354+
try:
4355+
payload["pfx_passphrase"] = credential_map["passphrase"]
4356+
except KeyError as exc:
4357+
msg = "PFX credentials require both 'pfx' and 'passphrase' files."
4358+
raise PdfRestConfigurationError(msg) from exc
4359+
elif "certificate" in credential_map:
4360+
payload["certificate"] = credential_map["certificate"]
4361+
try:
4362+
payload["private_key"] = credential_map["private_key"]
4363+
except KeyError as exc:
4364+
msg = "Certificate credentials require both 'certificate' and 'private_key' files."
4365+
raise PdfRestConfigurationError(msg) from exc
4366+
4367+
if logo is not None:
4368+
payload["logo"] = logo
4369+
if output is not None:
4370+
payload["output"] = output
4371+
4372+
return await self._post_file_operation(
4373+
endpoint="/signed-pdf",
4374+
payload=payload,
4375+
payload_model=PdfSignPayload,
4376+
extra_query=extra_query,
4377+
extra_headers=extra_headers,
4378+
extra_body=extra_body,
4379+
timeout=timeout,
4380+
)
4381+
42634382
async def flatten_transparencies(
42644383
self,
42654384
file: PdfRestFile | Sequence[PdfRestFile],

src/pdfrest/models/_internal.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ def _serialize_redactions(value: list[_PdfRedactionVariant]) -> str:
148148
return json.dumps(payload, separators=(",", ":"))
149149

150150

151+
def _serialize_signature_configuration(
152+
value: _PdfSignatureConfigurationModel,
153+
) -> str:
154+
payload = value.model_dump(mode="json", exclude_none=True)
155+
return json.dumps(payload, separators=(",", ":"))
156+
157+
151158
def _allowed_mime_types(
152159
allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None
153160
) -> Callable[[Any], Any]:
@@ -580,6 +587,34 @@ class PdfPresetRedactionModel(BaseModel):
580587
value: PdfRedactionPreset
581588

582589

590+
class _PdfSignaturePointModel(BaseModel):
591+
x: str | int | float
592+
y: str | int | float
593+
594+
595+
class _PdfSignatureLocationModel(BaseModel):
596+
bottom_left: _PdfSignaturePointModel
597+
top_right: _PdfSignaturePointModel
598+
page: str | int
599+
600+
601+
class _PdfSignatureDisplayModel(BaseModel):
602+
include_distinguished_name: bool | None = None
603+
include_datetime: bool | None = None
604+
contact: str | None = None
605+
location: str | None = None
606+
name: str | None = None
607+
reason: str | None = None
608+
609+
610+
class _PdfSignatureConfigurationModel(BaseModel):
611+
type: Literal["new"]
612+
name: str | None = None
613+
logo_opacity: Annotated[float | None, Field(ge=0, le=1, default=None)] = None
614+
location: _PdfSignatureLocationModel | None = None
615+
display: _PdfSignatureDisplayModel | None = None
616+
617+
583618
_PdfRedactionVariant = Annotated[
584619
PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel,
585620
Field(discriminator="type"),
@@ -963,6 +998,158 @@ class PdfFlattenFormsPayload(BaseModel):
963998
] = None
964999

9651000

1001+
class PdfSignPayload(BaseModel):
1002+
"""Adapt caller options into a pdfRest-ready sign request payload."""
1003+
1004+
files: Annotated[
1005+
list[PdfRestFile],
1006+
Field(
1007+
min_length=1,
1008+
max_length=1,
1009+
validation_alias=AliasChoices("file", "files"),
1010+
serialization_alias="id",
1011+
),
1012+
BeforeValidator(_ensure_list),
1013+
AfterValidator(
1014+
_allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
1015+
),
1016+
PlainSerializer(_serialize_as_first_file_id),
1017+
]
1018+
signature_configuration: Annotated[
1019+
_PdfSignatureConfigurationModel,
1020+
Field(serialization_alias="signature_configuration"),
1021+
PlainSerializer(_serialize_signature_configuration),
1022+
]
1023+
pfx_credential: Annotated[
1024+
list[PdfRestFile] | None,
1025+
Field(
1026+
default=None,
1027+
min_length=1,
1028+
max_length=1,
1029+
validation_alias=AliasChoices("pfx", "pfx_credential"),
1030+
serialization_alias="pfx_credential_id",
1031+
),
1032+
BeforeValidator(_ensure_list),
1033+
BeforeValidator(
1034+
_allowed_mime_types(
1035+
"application/x-pkcs12",
1036+
"application/pkcs12",
1037+
"application/octet-stream",
1038+
error_msg="PFX credentials must be a .pfx or .p12 file",
1039+
)
1040+
),
1041+
PlainSerializer(_serialize_as_first_file_id),
1042+
] = None
1043+
pfx_passphrase: Annotated[
1044+
list[PdfRestFile] | None,
1045+
Field(
1046+
default=None,
1047+
min_length=1,
1048+
max_length=1,
1049+
validation_alias=AliasChoices("pfx_passphrase", "passphrase"),
1050+
serialization_alias="pfx_passphrase_id",
1051+
),
1052+
BeforeValidator(_ensure_list),
1053+
BeforeValidator(
1054+
_allowed_mime_types(
1055+
"text/plain",
1056+
"application/octet-stream",
1057+
error_msg="PFX passphrase must be a text file",
1058+
)
1059+
),
1060+
PlainSerializer(_serialize_as_first_file_id),
1061+
] = None
1062+
certificate: Annotated[
1063+
list[PdfRestFile] | None,
1064+
Field(
1065+
default=None,
1066+
min_length=1,
1067+
max_length=1,
1068+
validation_alias=AliasChoices("certificate", "cert"),
1069+
serialization_alias="certificate_id",
1070+
),
1071+
BeforeValidator(_ensure_list),
1072+
BeforeValidator(
1073+
_allowed_mime_types(
1074+
"application/pkix-cert",
1075+
"application/x-x509-ca-cert",
1076+
"application/x-pem-file",
1077+
"application/octet-stream",
1078+
error_msg="Certificate must be a .pem or .der file",
1079+
)
1080+
),
1081+
PlainSerializer(_serialize_as_first_file_id),
1082+
] = None
1083+
private_key: Annotated[
1084+
list[PdfRestFile] | None,
1085+
Field(
1086+
default=None,
1087+
min_length=1,
1088+
max_length=1,
1089+
validation_alias=AliasChoices("private_key", "key"),
1090+
serialization_alias="private_key_id",
1091+
),
1092+
BeforeValidator(_ensure_list),
1093+
BeforeValidator(
1094+
_allowed_mime_types(
1095+
"application/pkcs8",
1096+
"application/x-pem-file",
1097+
"application/octet-stream",
1098+
error_msg="Private key must be a .pem or .der file",
1099+
)
1100+
),
1101+
PlainSerializer(_serialize_as_first_file_id),
1102+
] = None
1103+
logo: Annotated[
1104+
list[PdfRestFile] | None,
1105+
Field(
1106+
default=None,
1107+
min_length=1,
1108+
max_length=1,
1109+
validation_alias=AliasChoices("logo", "logos"),
1110+
serialization_alias="logo_id",
1111+
),
1112+
BeforeValidator(_ensure_list),
1113+
BeforeValidator(
1114+
_allowed_mime_types(
1115+
"image/jpeg",
1116+
"image/png",
1117+
"image/tiff",
1118+
"image/bmp",
1119+
error_msg="Logo must be an image file",
1120+
)
1121+
),
1122+
PlainSerializer(_serialize_as_first_file_id),
1123+
] = None
1124+
output: Annotated[
1125+
str | None,
1126+
Field(serialization_alias="output", min_length=1, default=None),
1127+
AfterValidator(_validate_output_prefix),
1128+
] = None
1129+
1130+
@model_validator(mode="after")
1131+
def _validate_credentials(self) -> PdfSignPayload:
1132+
has_pfx = self.pfx_credential is not None or self.pfx_passphrase is not None
1133+
has_pem = self.certificate is not None or self.private_key is not None
1134+
1135+
if has_pfx and has_pem:
1136+
msg = "Provide either PFX credentials (pfx + passphrase) or certificate/private_key, not both."
1137+
raise ValueError(msg)
1138+
if has_pfx:
1139+
if not self.pfx_credential or not self.pfx_passphrase:
1140+
msg = "Both pfx and passphrase are required when supplying PFX credentials."
1141+
raise ValueError(msg)
1142+
elif has_pem:
1143+
if not self.certificate or not self.private_key:
1144+
msg = "Both certificate and private_key are required when supplying PEM/DER credentials."
1145+
raise ValueError(msg)
1146+
else:
1147+
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
1148+
raise ValueError(msg)
1149+
1150+
return self
1151+
1152+
9661153
class PdfCompressPayload(BaseModel):
9671154
"""Adapt caller options into a pdfRest-ready compress request payload."""
9681155

src/pdfrest/types/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
PdfRedactionType,
2323
PdfRestriction,
2424
PdfRGBColor,
25+
PdfSignatureConfiguration,
26+
PdfSignatureCredentials,
27+
PdfSignatureDisplay,
28+
PdfSignatureLocation,
2529
PdfXType,
2630
PngColorModel,
2731
SummaryFormat,
@@ -53,6 +57,10 @@
5357
"PdfRedactionPreset",
5458
"PdfRedactionType",
5559
"PdfRestriction",
60+
"PdfSignatureConfiguration",
61+
"PdfSignatureCredentials",
62+
"PdfSignatureDisplay",
63+
"PdfSignatureLocation",
5664
"PdfXType",
5765
"PngColorModel",
5866
"SummaryFormat",

0 commit comments

Comments
 (0)