PDF Signoff
PDF Signoff is a local, agent-friendly command-line utility for placing one signature PNG on one or more PDF pages. It can either open a minimal browser UI for human review or apply a previously saved placement profile unattended. An optional L1 stage adds an invisible PDF digital signature for tamper evidence.
The CLI is the product API. Every successful signing invocation writes a separate PDF and emits exactly one reusable placement-profile JSON object on stdout. Status, warnings, and errors are written to stderr.
PDF Signoff is pre-alpha software. Review its output before relying on it.
For compatible AI agents, the portable
pdf-signoff skill describes a conservative
invocation and output-validation workflow. Install the complete
skills/pdf-signoff directory in the agent's skill location; the skill treats
the installed pdf-signoff --help as the current command reference.
Installation
PDF Signoff requires Python 3.13 or newer.
python3.13 -m pip install pdf-signoff
pdf-signoff --help
The wheel includes the production Vue/PDF.js review UI. Node.js is not required to run either review or automatic signing from an installed package.
To install the checked-out project with its locked development dependencies:
uv sync --frozen --group dev
uv run pdf-signoff --help
Review Mode
Review is the default mode. This starts a protected one-shot web server on a loopback address, opens the review page in the default browser, and waits until Save is selected:
pdf-signoff report.pdf --signature signature.png > report-placement.json
The UI starts empty when --coords is omitted. Select Place signature, then
move over a PDF page to preview the signature and click to add it. The preview
is fully clamped within the page. A placement can be moved, resized with its
aspect ratio locked, removed from its right-click menu, or removed with the
Delete key while selected. A resized width becomes the size for later placements
in that review session. The only normal visible toolbar controls are
Place signature and Save. Save writes the configured output directly;
it does not open a filename dialog or download a file through the browser.
Supply an existing profile to preload editable suggestions:
pdf-signoff report.pdf \
--signature signature.png \
--coords report-placement.json \
--review \
> reviewed-placement.json
The emitted profile contains the final edited placements, not merely the preloaded suggestions. Closing the browser without saving does not cancel the operation; the CLI continues waiting. Press Ctrl+C to stop it without emitting a profile or creating an output.
Automatic Mode
Automatic signing is always explicit and requires a non-empty placement profile:
pdf-signoff report.pdf \
--signature signature.png \
--coords reviewed-placement.json \
--auto \
> final-placement.json
Supplying --coords alone never enables unattended signing. --review and
--auto are mutually exclusive.
Before using a profile, PDF Signoff requires its page count, listed displayed
CropBox dimensions (within 1 PDF point), rotations, and optional required-text
conditions to match the current PDF. The source SHA-256 in created_from is
informational so a profile can be reused for changing instances of the same
form.
Output And Capture
Unless --output is supplied, the output suffix is inserted before the final
extension:
report.pdf -> report_signed.pdf
report.v2.pdf -> report.v2_signed.pdf
An explicit destination takes precedence over the configured suffix:
pdf-signoff report.pdf \
--signature signature.png \
--coords reviewed-placement.json \
--auto \
--output /tmp/report-approved.pdf \
> /tmp/report-approved-placement.json
The input PDF is never modified in place, and input and output may not resolve
to the same file. Existing destinations are refused unless --overwrite is
explicit:
pdf-signoff report.pdf \
--signature signature.png \
--coords reviewed-placement.json \
--auto \
--output report-approved.pdf \
--overwrite \
> report-approved-placement.json
Output is built in a temporary file in the destination directory and published
only after every requested stage succeeds. With --overwrite, replacement is
atomic. Without it, PDF Signoff also prevents a destination created by another
process while signing from being replaced. Published PDFs use POSIX mode 0644
(owner read/write; group and other read), regardless of the process umask.
On success, stdout is one compact JSON document followed by a newline and the
exit status is zero. At the default INFO log level, stderr reports the output
path; higher log levels may suppress that informational message. On failure,
stdout is empty, the exit status is non-zero, and no partial output is committed.
Automation should select an explicit --output rather than parse stderr. Keep
stdout separate when an agent needs to capture the final profile while still
allowing diagnostics to reach the terminal:
pdf-signoff report.pdf --signature signature.png > placement.json
Placement Profiles
Version 1 profiles use normalized coordinates against the displayed page CropBox after rotation. The origin is at the top left, x increases right, y increases down, values are in the range 0 through 1, and page numbers are 1-based.
{
"version": 1,
"created_from": {
"sha256": "<informational SHA-256 of the source PDF>"
},
"match": {
"page_count": 1,
"pages": [
{
"page": 1,
"width_pt": 600.0,
"height_pt": 800.0,
"rotation": 0
}
],
"required_text": [
{
"page": 1,
"text": "Employee signature:"
}
]
},
"placements": [
{
"page": 1,
"x": 0.62,
"y": 0.72,
"width": 0.24,
"height": 0.045
}
]
}
created_from and match.required_text are optional. PDF Signoff preserves
input required-text conditions in the final profile but cannot infer them for a
new profile. match.pages must contain exactly one metadata entry for every
page from 1 through match.page_count. Each placement must have positive width
and height and remain fully within its referenced page. Its physical PDF
rectangle must also match the PNG aspect ratio within the backend tolerance;
malformed hand-edited profiles are rejected rather than distorted.
Configuration
PDF Signoff uses Onacol with schema-backed YAML configuration. Generate a schema-free template without supplying signing arguments:
pdf-signoff --get-config-template config.yaml
The generated values are:
general:
log_level: INFO
output_suffix: _signed
default_level: l0
default_signature_width: 0.16
open_browser: true
host: 127.0.0.1
port: 0
l1:
pkcs12_path: ''
password_env: PDF_SIGN_PKCS12_PASSWORD
reason: Internal document approval
Configuration precedence from lowest to highest is:
- Bundled defaults.
- One user YAML file: explicit
--config PATH, otherwise$XDG_CONFIG_HOME/pdf-signoff/config.yaml, otherwise~/.config/pdf-signoff/config.yaml. - Environment variables such as
PDF_SIGNOFF__DEFAULT_LEVELandPDF_SIGNOFF__L1__PKCS12_PATH. - Nested Onacol CLI options such as
--output-suffix _approvedor--general--log-level DEBUG.
For example:
PDF_SIGNOFF__DEFAULT_LEVEL=l0 \
pdf-signoff report.pdf \
--signature signature.png \
--coords reviewed-placement.json \
--auto \
--output-suffix _approved \
> approved-placement.json
An explicit config file replaces, rather than layers on top of, a discovered
user file. Environment variables and ~ in l1.pkcs12_path are expanded after
configuration is merged and validated. Port 0 asks the operating system for
an available ephemeral port. The review host must parse as a loopback IP
address; remote binding is rejected. general.log_level controls application
and review-server diagnostics on stderr; DEBUG adds workflow details without
logging session credentials or L1 key material.
L0 And L1
--level l0 embeds the supplied PNG at every final placement. Transparency is
preserved. L0 does not add a cryptographic signature and makes no claim that
the PDF has not subsequently changed. It does not create Adobe Fill & Sign
proprietary metadata.
--level l1 first produces the same complete visual result, then adds an
invisible pyHanko approval signature over that revision before the output is
committed. L1 can detect changes to the covered revision, including changes to
the visible image or its placement. It does not prove who controlled the key,
who reviewed the document, or whether a certificate should be trusted.
L1 requires a PKCS#12 .p12 or .pfx signer bundle. Configure its path, the
name of the password environment variable, and the non-secret reason:
[!TIP] Create a local signer bundle For development or internal use, create a self-signed certificate and bundle it with its private key using OpenSSL. Run this in a private temporary directory; OpenSSL prompts for the bundle password.
openssl req -x509 -newkey rsa:3072 -nodes -days 365 \ -keyout signer-key.pem -out signer-cert.pem -subj '/CN=PDF Signoff' openssl pkcs12 -export -out signing.p12 \ -inkey signer-key.pem -in signer-cert.pem -name 'PDF Signoff' rm signer-key.pem signer-cert.pemMove
signing.p12to protected storage. A self-signed certificate is not trusted by default.
l1:
pkcs12_path: ~/.config/pdf-signoff/signing.p12
password_env: PDF_SIGN_PKCS12_PASSWORD
reason: Internal document approval
Then set the password without putting it in command arguments and request L1:
export PDF_SIGN_PKCS12_PASSWORD='read-secret-from-your-secret-store'
pdf-signoff report.pdf \
--signature signature.png \
--coords reviewed-placement.json \
--auto \
--level l1 \
> integrity-placement.json
If the configured password variable is absent, an interactive TTY may use a secure password prompt. Non-interactive runs fail instead. Protect the PKCS#12 file and password as private signing credentials. PDF Signoff does not print passwords, private-key material, or sensitive signer details. Missing, invalid, or unreadable credentials fail without committing an output or emitting a profile.
A self-signed certificate can provide integrity and key continuity while still being untrusted. PDF Signoff does not perform public-CA identity validation, certificate trust management, timestamping, revocation embedding, or long-term validation. L1 is not a legal, qualified, or externally verified human signature claim.
Previously Signed Inputs
PDF Signoff refuses an input containing an existing filled PDF cryptographic signature field before opening review or creating output. Rewriting or stamping such a PDF may invalidate its previous signatures.
--allow-signed-input is an expert override:
pdf-signoff previously-signed.pdf \
--signature signature.png \
--coords reviewed-placement.json \
--auto \
--allow-signed-input \
> final-placement.json
The override emits a strong warning only to stderr. It permits processing but does not validate previous signatures or claim that any previous signature remains valid.
Local Review Security
Review mode is a local desktop session, not a network service:
- The server accepts only loopback IP addresses and defaults to
127.0.0.1on an operating-system-selected port. - One random 256-bit credential protects one signing session. The bootstrap URL transfers it to an HttpOnly, SameSite=Strict cookie and redirects to remove it from the address; bearer authentication is also supported by the backend.
- API requests without the active credential and unrelated browser origins are rejected. No permissive CORS policy is enabled.
- The server exposes only the invocation-selected PDF and PNG, path-free session metadata, fixed frontend assets, and a placement-only save operation. It has no generic file API or directory listing.
- Access logging is disabled to avoid routinely logging token-bearing URLs, and the session is invalidated after its one successful save or server shutdown.
The server uses plain HTTP on loopback, so its session cookie is intentionally
not marked Secure. The security boundary assumes the CLI, browser, operating
system account, and local machine are trusted. Other processes running as the
same user, browser extensions, endpoint compromise, or disclosure of the
bootstrap URL are outside that boundary. Do not expose or proxy the review
server, share the tokenized URL, or configure it as a multi-user service.
Supported And Rejected Inputs
The MVP supports readable, unencrypted, non-empty normal PDFs and one decodable PNG signature asset per invocation. It supports multiple placements and pages, displayed CropBoxes, non-zero page-box origins, arbitrary normal page sizes, and rotations of 0, 90, 180, and 270 degrees.
It rejects these inputs or invocations conservatively:
- Encrypted or password-protected PDFs.
- Malformed, repaired, unreadable, non-PDF, or zero-page documents.
- Non-PNG or undecodable signature assets.
- Invalid, empty-in-auto-mode, out-of-range, or aspect-inconsistent placements.
- Unsupported profile versions and profile/document page, geometry, rotation, or required-text mismatches.
- An output equal to the input, or an existing output without
--overwrite. --autowithout--coords.- L1 without available valid signer credentials.
- Previously signed PDFs without
--allow-signed-input.
Required-text matching uses text extractable by PyMuPDF. There is no OCR or PDF password-unlock workflow.
Responsibility Boundary
PDF Signoff deliberately does not decide whether a document should be signed. The calling user, agent, or workflow is responsible for deciding:
- Which PDF should be signed.
- Which signature PNG and signing credential may be used.
- Which stored placement profile applies to the document.
- Whether human review is required or unattended
--autois appropriate. - Whether L0 or L1 meets the workflow's policy and legal requirements.
- Whether the final PDF and emitted profile should be accepted or distributed.
PDF Signoff only validates the supplied inputs and profile, obtains or reviews placements, creates a separate output, optionally seals that revision for integrity, and returns the final normalized profile. An external workflow must provide authorization, identity, approvals, storage, retention, and audit policy.
MVP Non-Goals
The MVP does not provide:
- Legal or qualified electronic signatures.
- Public-CA identity validation.
- Timestamp authorities.
- Long-term PAdES validation, including PAdES LT/LTA.
- OCSP/CRL embedding, certificate-chain trust management, or other revocation and trust services.
- Adobe Fill & Sign proprietary metadata compatibility.
- Handwriting capture or image editing.
- OCR-based automatic signature-position discovery.
- Automatic document or template classification.
- Multi-user server deployment or a remote web service.
- Cloud storage integration.
- PDF form filling beyond signature placement.
- Password-protected PDF support.
- Multiple different signature PNG identities in one invocation.
- An audit database or workflow engine.
Development Verification
The release checks use the Python and Node lockfiles:
uv sync --frozen --group dev
uv lock --check
uv run pytest --cov=pdf_signoff tests/
uv run ruff check .
uv run ruff format --check .
uv run mypy pdf_signoff
uv run tox
cd frontend
npm ci
npm audit
npm run typecheck
npm test
npm run build
npm run test:e2e
The configured Python coverage threshold is 100%. Playwright requires its
Chromium browser and host dependencies in the development environment. Building
the release artifacts uses uv build; running the resulting wheel does not
require Node.js, npm, Vite, Vitest, or Playwright.
License
PDF Signoff is distributed under the MIT license.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pdf_signoff-0.1.2.tar.gz.
File metadata
- Download URL: pdf_signoff-0.1.2.tar.gz
- Upload date:
- Size: 693.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
83faa5d95211fec26d543989d58ddb6cbcbed873929b90e71bb678db56a70fa4
|
|
| MD5 |
6119f99e9e76149ece310ad04fec24ab
|
|
| BLAKE2b-256 |
5282fa2e7dbbde5e65ae9cd70231e31e0eb8beaba169f7ef88a4c43c0707e70e
|
Provenance
The following attestation bundles were made for pdf_signoff-0.1.2.tar.gz:
Publisher:
release.yml on calcite/pdf-signoff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pdf_signoff-0.1.2.tar.gz -
Subject digest:
83faa5d95211fec26d543989d58ddb6cbcbed873929b90e71bb678db56a70fa4 - Sigstore transparency entry: 2781766234
- Sigstore integration time:
-
Permalink:
calcite/pdf-signoff@97df8187dd00b2a08b29026eb9bd382bacb30439 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/calcite
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@97df8187dd00b2a08b29026eb9bd382bacb30439 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pdf_signoff-0.1.2-py3-none-any.whl.
File metadata
- Download URL: pdf_signoff-0.1.2-py3-none-any.whl
- Upload date:
- Size: 481.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d6ae7a01d7f87145a8fa4f7d3bce4817486c0043fba05c2eab6a74efc8db547b
|
|
| MD5 |
d530a710ba81650d3db0c2589ae8f229
|
|
| BLAKE2b-256 |
648b9b41705b4b676dceaa204ff0432aaf581530516af50d2ffc67d9e3ac9eb1
|
Provenance
The following attestation bundles were made for pdf_signoff-0.1.2-py3-none-any.whl:
Publisher:
release.yml on calcite/pdf-signoff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pdf_signoff-0.1.2-py3-none-any.whl -
Subject digest:
d6ae7a01d7f87145a8fa4f7d3bce4817486c0043fba05c2eab6a74efc8db547b - Sigstore transparency entry: 2781766506
- Sigstore integration time:
-
Permalink:
calcite/pdf-signoff@97df8187dd00b2a08b29026eb9bd382bacb30439 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/calcite
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@97df8187dd00b2a08b29026eb9bd382bacb30439 -
Trigger Event:
push
-
Statement type: