Skip to content

Commit 834080d

Browse files
Add Sign PDF tool
Assisted-by: Codex
1 parent f6bb554 commit 834080d

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
@@ -92,6 +92,7 @@
9292
PdfRedactionApplyPayload,
9393
PdfRedactionPreviewPayload,
9494
PdfRestRawFileResponse,
95+
PdfSignPayload,
9596
PdfSplitPayload,
9697
PdfToExcelPayload,
9798
PdfToPdfaPayload,
@@ -121,6 +122,8 @@
121122
PdfPageSelection,
122123
PdfRedactionInstruction,
123124
PdfRGBColor,
125+
PdfSignatureConfiguration,
126+
PdfSignatureCredentials,
124127
PdfXType,
125128
PngColorModel,
126129
SummaryFormat,
@@ -2717,6 +2720,64 @@ def compress_pdf(
27172720
timeout=timeout,
27182721
)
27192722

2723+
def sign_pdf(
2724+
self,
2725+
file: PdfRestFile | Sequence[PdfRestFile],
2726+
*,
2727+
signature_configuration: PdfSignatureConfiguration,
2728+
credentials: PdfSignatureCredentials,
2729+
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
2730+
output: str | None = None,
2731+
extra_query: Query | None = None,
2732+
extra_headers: AnyMapping | None = None,
2733+
extra_body: Body | None = None,
2734+
timeout: TimeoutTypes | None = None,
2735+
) -> PdfRestFileBasedResponse:
2736+
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""
2737+
2738+
payload: dict[str, Any] = {
2739+
"files": file,
2740+
"signature_configuration": signature_configuration,
2741+
}
2742+
2743+
credential_map = cast(Mapping[str, PdfRestFile], credentials)
2744+
2745+
if "pfx" in credential_map and "certificate" in credential_map:
2746+
msg = "Provide either PFX credentials or certificate/private_key, not both."
2747+
raise PdfRestConfigurationError(msg)
2748+
if "pfx" not in credential_map and "certificate" not in credential_map:
2749+
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
2750+
raise PdfRestConfigurationError(msg)
2751+
if "pfx" in credential_map:
2752+
payload["pfx_credential"] = credential_map["pfx"]
2753+
try:
2754+
payload["pfx_passphrase"] = credential_map["passphrase"]
2755+
except KeyError as exc:
2756+
msg = "PFX credentials require both 'pfx' and 'passphrase' files."
2757+
raise PdfRestConfigurationError(msg) from exc
2758+
elif "certificate" in credential_map:
2759+
payload["certificate"] = credential_map["certificate"]
2760+
try:
2761+
payload["private_key"] = credential_map["private_key"]
2762+
except KeyError as exc:
2763+
msg = "Certificate credentials require both 'certificate' and 'private_key' files."
2764+
raise PdfRestConfigurationError(msg) from exc
2765+
2766+
if logo is not None:
2767+
payload["logo"] = logo
2768+
if output is not None:
2769+
payload["output"] = output
2770+
2771+
return self._post_file_operation(
2772+
endpoint="/signed-pdf",
2773+
payload=payload,
2774+
payload_model=PdfSignPayload,
2775+
extra_query=extra_query,
2776+
extra_headers=extra_headers,
2777+
extra_body=extra_body,
2778+
timeout=timeout,
2779+
)
2780+
27202781
def flatten_transparencies(
27212782
self,
27222783
file: PdfRestFile | Sequence[PdfRestFile],
@@ -3754,6 +3815,64 @@ async def compress_pdf(
37543815
timeout=timeout,
37553816
)
37563817

3818+
async def sign_pdf(
3819+
self,
3820+
file: PdfRestFile | Sequence[PdfRestFile],
3821+
*,
3822+
signature_configuration: PdfSignatureConfiguration,
3823+
credentials: PdfSignatureCredentials,
3824+
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
3825+
output: str | None = None,
3826+
extra_query: Query | None = None,
3827+
extra_headers: AnyMapping | None = None,
3828+
extra_body: Body | None = None,
3829+
timeout: TimeoutTypes | None = None,
3830+
) -> PdfRestFileBasedResponse:
3831+
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""
3832+
3833+
payload: dict[str, Any] = {
3834+
"files": file,
3835+
"signature_configuration": signature_configuration,
3836+
}
3837+
3838+
credential_map = cast(Mapping[str, PdfRestFile], credentials)
3839+
3840+
if "pfx" in credential_map and "certificate" in credential_map:
3841+
msg = "Provide either PFX credentials or certificate/private_key, not both."
3842+
raise PdfRestConfigurationError(msg)
3843+
if "pfx" not in credential_map and "certificate" not in credential_map:
3844+
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
3845+
raise PdfRestConfigurationError(msg)
3846+
if "pfx" in credential_map:
3847+
payload["pfx_credential"] = credential_map["pfx"]
3848+
try:
3849+
payload["pfx_passphrase"] = credential_map["passphrase"]
3850+
except KeyError as exc:
3851+
msg = "PFX credentials require both 'pfx' and 'passphrase' files."
3852+
raise PdfRestConfigurationError(msg) from exc
3853+
elif "certificate" in credential_map:
3854+
payload["certificate"] = credential_map["certificate"]
3855+
try:
3856+
payload["private_key"] = credential_map["private_key"]
3857+
except KeyError as exc:
3858+
msg = "Certificate credentials require both 'certificate' and 'private_key' files."
3859+
raise PdfRestConfigurationError(msg) from exc
3860+
3861+
if logo is not None:
3862+
payload["logo"] = logo
3863+
if output is not None:
3864+
payload["output"] = output
3865+
3866+
return await self._post_file_operation(
3867+
endpoint="/signed-pdf",
3868+
payload=payload,
3869+
payload_model=PdfSignPayload,
3870+
extra_query=extra_query,
3871+
extra_headers=extra_headers,
3872+
extra_body=extra_body,
3873+
timeout=timeout,
3874+
)
3875+
37573876
async def flatten_transparencies(
37583877
self,
37593878
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
@@ -20,6 +20,10 @@
2020
PdfRedactionPreset,
2121
PdfRedactionType,
2222
PdfRGBColor,
23+
PdfSignatureConfiguration,
24+
PdfSignatureCredentials,
25+
PdfSignatureDisplay,
26+
PdfSignatureLocation,
2327
PdfXType,
2428
PngColorModel,
2529
SummaryFormat,
@@ -49,6 +53,10 @@
4953
"PdfRedactionInstruction",
5054
"PdfRedactionPreset",
5155
"PdfRedactionType",
56+
"PdfSignatureConfiguration",
57+
"PdfSignatureCredentials",
58+
"PdfSignatureDisplay",
59+
"PdfSignatureLocation",
5260
"PdfXType",
5361
"PngColorModel",
5462
"SummaryFormat",

0 commit comments

Comments
 (0)