Skip to content
Closed
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
75 changes: 75 additions & 0 deletions src/pdfrest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
PdfRedactionPreviewPayload,
PdfRestRawFileResponse,
PdfRestrictPayload,
PdfSignPayload,
PdfSplitPayload,
PdfToExcelPayload,
PdfToPdfaPayload,
Expand Down Expand Up @@ -131,6 +132,8 @@
PdfRedactionInstruction,
PdfRestriction,
PdfRGBColor,
PdfSignatureConfiguration,
PdfSignatureCredentials,
PdfXType,
PngColorModel,
SummaryFormat,
Expand Down Expand Up @@ -3070,6 +3073,42 @@ def add_attachment_to_pdf(
timeout=timeout,
)

def sign_pdf(
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.

Since I was on the topic of "things we split into separate functions for clarity", pdfAssistant has

pdfassistant_chatbot/functions/signed_pdf.py
255:async def digitally_sign_pdf_with_pfx(
362:async def digitally_sign_pdf_with_non_pfx(

...should that idea be surfaced here? Just trying to keep the interface grounded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is what I have now done with Watermark PDF and what I'm currently working on for /pdf.

self,
file: PdfRestFile | Sequence[PdfRestFile],
*,
signature_configuration: PdfSignatureConfiguration,
credentials: PdfSignatureCredentials,
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
output: str | None = None,
extra_query: Query | None = None,
extra_headers: AnyMapping | None = None,
extra_body: Body | None = None,
timeout: TimeoutTypes | None = None,
) -> PdfRestFileBasedResponse:
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""

payload: dict[str, Any] = {
"files": file,
"signature_configuration": signature_configuration,
}
payload.update(credentials)

if logo is not None:
payload["logo"] = logo
if output is not None:
payload["output"] = output

return self._post_file_operation(
endpoint="/signed-pdf",
payload=payload,
payload_model=PdfSignPayload,
extra_query=extra_query,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
)

def flatten_transparencies(
self,
file: PdfRestFile | Sequence[PdfRestFile],
Expand Down Expand Up @@ -4450,6 +4489,42 @@ async def add_attachment_to_pdf(
timeout=timeout,
)

async def sign_pdf(
self,
file: PdfRestFile | Sequence[PdfRestFile],
*,
signature_configuration: PdfSignatureConfiguration,
credentials: PdfSignatureCredentials,
logo: PdfRestFile | Sequence[PdfRestFile] | None = None,
output: str | None = None,
extra_query: Query | None = None,
extra_headers: AnyMapping | None = None,
extra_body: Body | None = None,
timeout: TimeoutTypes | None = None,
) -> PdfRestFileBasedResponse:
"""Digitally sign a PDF using PFX credentials or a certificate/private key."""

payload: dict[str, Any] = {
"files": file,
"signature_configuration": signature_configuration,
}
payload.update(credentials)

if logo is not None:
payload["logo"] = logo
if output is not None:
payload["output"] = output

return await self._post_file_operation(
endpoint="/signed-pdf",
payload=payload,
payload_model=PdfSignPayload,
extra_query=extra_query,
extra_headers=extra_headers,
extra_body=extra_body,
timeout=timeout,
)

async def flatten_transparencies(
self,
file: PdfRestFile | Sequence[PdfRestFile],
Expand Down
194 changes: 194 additions & 0 deletions src/pdfrest/models/_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ def _serialize_text_objects(value: list[BaseModel]) -> str:
return to_json(payload).decode()


def _serialize_signature_configuration(
value: _PdfSignatureConfigurationModel,
) -> str:
payload = value.model_dump(mode="json", exclude_none=True)
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.

Use model_dump_json instead of model_dump followed by json.dumps.

return to_json(payload).decode()


def _allowed_mime_types(
allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None
) -> Callable[[Any], Any]:
Expand Down Expand Up @@ -603,6 +610,41 @@ class PdfPresetRedactionModel(BaseModel):
value: PdfRedactionPreset


class _PdfSignaturePointModel(BaseModel):
x: str | int | float
y: str | int | float
Comment on lines +614 to +615
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.

Use float.



class _PdfSignatureLocationModel(BaseModel):
bottom_left: _PdfSignaturePointModel
top_right: _PdfSignaturePointModel
page: str | int


class _PdfSignatureDisplayModel(BaseModel):
include_distinguished_name: bool | None = None
include_datetime: bool | None = None
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.

Are these bools really optional? Optional bool can be confusing...and the TypedDict doesn't have optional values. This is the part that validates, and makes sure that at runtime, the TypedDict got passed the correct values.

type checking -> IDE time
validation -> runtime

contact: str | None = None
location: str | None = None
name: str | None = None
reason: str | None = None
Comment on lines +627 to +630
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.

According to the types on PdfSignatureDisplay, these are not optional. They should validate how they're typed on the public interface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are meant to be optional. If they are not wanted, then they should be omitted from the request.



class _PdfSignatureConfigurationModel(BaseModel):
type: Literal["new"]
name: str | None = None
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.

But the public interface says this isn't optional...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's required.

logo_opacity: Annotated[float | None, Field(ge=0, le=1, default=None)] = None
location: _PdfSignatureLocationModel | None = None
display: _PdfSignatureDisplayModel | None = None

@model_validator(mode="after")
def _validate_location_for_new_type(self) -> _PdfSignatureConfigurationModel:
if self.type == "new" and self.location is None:
msg = "Missing location information for a new digital signature field."
raise ValueError(msg)
return self


_PdfRedactionVariant = Annotated[
PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel,
Field(discriminator="type"),
Expand Down Expand Up @@ -986,6 +1028,158 @@ class PdfFlattenFormsPayload(BaseModel):
] = None


class PdfSignPayload(BaseModel):
"""Adapt caller options into a pdfRest-ready sign request payload."""

files: Annotated[
list[PdfRestFile],
Field(
min_length=1,
max_length=1,
validation_alias=AliasChoices("file", "files"),
serialization_alias="id",
),
BeforeValidator(_ensure_list),
AfterValidator(
_allowed_mime_types("application/pdf", error_msg="Must be a PDF file")
),
PlainSerializer(_serialize_as_first_file_id),
]
signature_configuration: Annotated[
_PdfSignatureConfigurationModel,
Field(serialization_alias="signature_configuration"),
PlainSerializer(_serialize_signature_configuration),
]
pfx_credential: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("pfx", "pfx_credential"),
serialization_alias="pfx_credential_id",
),
BeforeValidator(_ensure_list),
BeforeValidator(
_allowed_mime_types(
"application/x-pkcs12",
"application/pkcs12",
"application/octet-stream",
error_msg="PFX credentials must be a .pfx or .p12 file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
pfx_passphrase: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("pfx_passphrase", "passphrase"),
serialization_alias="pfx_passphrase_id",
),
BeforeValidator(_ensure_list),
BeforeValidator(
_allowed_mime_types(
"text/plain",
"application/octet-stream",
error_msg="PFX passphrase must be a text file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
certificate: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("certificate", "cert"),
serialization_alias="certificate_id",
),
BeforeValidator(_ensure_list),
BeforeValidator(
_allowed_mime_types(
"application/pkix-cert",
"application/x-x509-ca-cert",
"application/x-pem-file",
"application/octet-stream",
error_msg="Certificate must be a .pem or .der file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
private_key: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("private_key", "key"),
serialization_alias="private_key_id",
),
BeforeValidator(_ensure_list),
BeforeValidator(
_allowed_mime_types(
"application/pkcs8",
"application/x-pem-file",
"application/octet-stream",
error_msg="Private key must be a .pem or .der file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
logo: Annotated[
list[PdfRestFile] | None,
Field(
default=None,
min_length=1,
max_length=1,
validation_alias=AliasChoices("logo", "logos"),
serialization_alias="logo_id",
),
BeforeValidator(_ensure_list),
BeforeValidator(
_allowed_mime_types(
"image/jpeg",
"image/png",
"image/tiff",
"image/bmp",
error_msg="Logo must be an image file",
)
),
PlainSerializer(_serialize_as_first_file_id),
] = None
output: Annotated[
str | None,
Field(serialization_alias="output", min_length=1, default=None),
AfterValidator(_validate_output_prefix),
] = None

@model_validator(mode="after")
def _validate_credentials(self) -> PdfSignPayload:
has_pfx = self.pfx_credential is not None or self.pfx_passphrase is not None
has_pem = self.certificate is not None or self.private_key is not None

if has_pfx and has_pem:
msg = "Provide either PFX credentials (pfx + passphrase) or certificate/private_key, not both."
raise ValueError(msg)
if has_pfx:
if not self.pfx_credential or not self.pfx_passphrase:
msg = "Both pfx and passphrase are required when supplying PFX credentials."
raise ValueError(msg)
elif has_pem:
if not self.certificate or not self.private_key:
msg = "Both certificate and private_key are required when supplying PEM/DER credentials."
raise ValueError(msg)
else:
msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required."
raise ValueError(msg)

return self


class PdfCompressPayload(BaseModel):
"""Adapt caller options into a pdfRest-ready compress request payload."""

Expand Down
8 changes: 8 additions & 0 deletions src/pdfrest/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
PdfRedactionType,
PdfRestriction,
PdfRGBColor,
PdfSignatureConfiguration,
PdfSignatureCredentials,
PdfSignatureDisplay,
PdfSignatureLocation,
PdfXType,
PngColorModel,
SummaryFormat,
Expand Down Expand Up @@ -57,6 +61,10 @@
"PdfRedactionPreset",
"PdfRedactionType",
"PdfRestriction",
"PdfSignatureConfiguration",
"PdfSignatureCredentials",
"PdfSignatureDisplay",
"PdfSignatureLocation",
"PdfXType",
"PngColorModel",
"SummaryFormat",
Expand Down
45 changes: 45 additions & 0 deletions src/pdfrest/types/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
"PdfRedactionPreset",
"PdfRedactionType",
"PdfRestriction",
"PdfSignatureConfiguration",
"PdfSignatureCredentials",
"PdfSignatureDisplay",
"PdfSignatureLocation",
"PdfXType",
"PngColorModel",
"SummaryFormat",
Expand Down Expand Up @@ -136,6 +140,47 @@ class PdfMergeSource(TypedDict, total=False):

PdfMergeInput = PdfRestFile | PdfMergeSource | tuple[PdfRestFile, PdfPageSelection]


class PdfSignaturePoint(TypedDict):
x: str | int | float
y: str | int | float
Comment on lines +145 to +146
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.

Use float as a type here.



class PdfSignatureLocation(TypedDict):
bottom_left: Required[PdfSignaturePoint]
top_right: Required[PdfSignaturePoint]
page: Required[str | int]
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.

I'm wondering...can this still be typed...maybe a Pydantic annotation won't help on the int but probably that str is a Literal really.



class PdfSignatureDisplay(TypedDict, total=False):
include_distinguished_name: bool
include_datetime: bool
contact: str
location: str
name: str
reason: str


class PdfSignatureConfiguration(TypedDict, total=False):
type: Required[Literal["new"]]
location: Required[PdfSignatureLocation]
name: str
logo_opacity: float
display: PdfSignatureDisplay


class PdfPfxCredentials(TypedDict):
pfx: Required[PdfRestFile]
passphrase: Required[PdfRestFile]


class PdfPemCredentials(TypedDict):
certificate: Required[PdfRestFile]
private_key: Required[PdfRestFile]


PdfSignatureCredentials = PdfPfxCredentials | PdfPemCredentials

PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"]
PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"]
ExtractTextGranularity = Literal["off", "by_page", "document"]
Expand Down
Loading
Loading