pdfform 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdfform-0.2.0/.github/dependabot.yml +16 -0
- pdfform-0.2.0/.github/workflows/check-pr.yml +83 -0
- pdfform-0.2.0/.github/workflows/ci.yml +90 -0
- pdfform-0.2.0/.github/workflows/publish.yml +52 -0
- pdfform-0.2.0/.github/workflows/release-please.yml +79 -0
- pdfform-0.2.0/.gitignore +19 -0
- pdfform-0.2.0/.release-please-manifest.json +3 -0
- pdfform-0.2.0/CHANGELOG.md +8 -0
- pdfform-0.2.0/CONTRIBUTING.md +76 -0
- pdfform-0.2.0/LICENSE +14 -0
- pdfform-0.2.0/PKG-INFO +163 -0
- pdfform-0.2.0/README.md +149 -0
- pdfform-0.2.0/environment-dev.yml +7 -0
- pdfform-0.2.0/pyproject.toml +95 -0
- pdfform-0.2.0/release-please-config.json +12 -0
- pdfform-0.2.0/src/pdfform/__init__.py +63 -0
- pdfform-0.2.0/src/pdfform/cli.py +376 -0
- pdfform-0.2.0/src/pdfform/extract.py +445 -0
- pdfform-0.2.0/src/pdfform/fill.py +336 -0
- pdfform-0.2.0/src/pdfform/flatten.py +246 -0
- pdfform-0.2.0/src/pdfform/labels.py +160 -0
- pdfform-0.2.0/src/pdfform/model.py +237 -0
- pdfform-0.2.0/src/pdfform/py.typed +0 -0
- pdfform-0.2.0/src/pdfform/schema.py +295 -0
- pdfform-0.2.0/src/pdfform/xfa.py +90 -0
- pdfform-0.2.0/tests/conftest.py +45 -0
- pdfform-0.2.0/tests/formbuilder.py +253 -0
- pdfform-0.2.0/tests/test_cli.py +242 -0
- pdfform-0.2.0/tests/test_extract.py +127 -0
- pdfform-0.2.0/tests/test_fill.py +237 -0
- pdfform-0.2.0/tests/test_flatten.py +156 -0
- pdfform-0.2.0/tests/test_schema.py +197 -0
- pdfform-0.2.0/tests/test_xfa.py +87 -0
- pdfform-0.2.0/uv.lock +883 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
|
|
3
|
+
updates:
|
|
4
|
+
- package-ecosystem: github-actions
|
|
5
|
+
directory: /
|
|
6
|
+
schedule:
|
|
7
|
+
interval: monthly
|
|
8
|
+
# Actions are pinned to commit SHAs, so Dependabot is what keeps them
|
|
9
|
+
# current. The `ci` prefix makes its PR titles pass the Conventional
|
|
10
|
+
# Commits title lint, and keeps the bumps out of the changelog.
|
|
11
|
+
commit-message:
|
|
12
|
+
prefix: ci
|
|
13
|
+
groups:
|
|
14
|
+
github-actions:
|
|
15
|
+
patterns:
|
|
16
|
+
- "*"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
name: PR Title Lint
|
|
2
|
+
|
|
3
|
+
# SECURITY NOTE: This workflow uses `pull_request_target` so that the
|
|
4
|
+
# title-lint can write commit statuses on PRs from forks (the default
|
|
5
|
+
# `pull_request` token is read-only for forks).
|
|
6
|
+
#
|
|
7
|
+
# `pull_request_target` runs with a privileged token in the context of
|
|
8
|
+
# the BASE branch. That is only safe because this workflow:
|
|
9
|
+
# - never checks out or executes PR code (no actions/checkout),
|
|
10
|
+
# - never interpolates PR-controlled data (title, body, branch name)
|
|
11
|
+
# into `run:` scripts,
|
|
12
|
+
# - grants nothing by default (`permissions: {}` below), so only the job
|
|
13
|
+
# that reads the PR title opts in, to `pull-requests: read` plus
|
|
14
|
+
# `statuses: write` and nothing else.
|
|
15
|
+
#
|
|
16
|
+
# DO NOT add a checkout of PR code or run any PR-provided content here.
|
|
17
|
+
# Doing so would let a fork execute arbitrary code with write access to
|
|
18
|
+
# this repository ("pwn request"). If code from the PR ever needs to be
|
|
19
|
+
# tested, use a separate workflow with the plain `pull_request` trigger.
|
|
20
|
+
#
|
|
21
|
+
# `merge_group` only runs the aggregator, so the required "PR Title Lint"
|
|
22
|
+
# check reports on merge queue commits. The title was already linted before
|
|
23
|
+
# the PR could enter the queue.
|
|
24
|
+
on:
|
|
25
|
+
pull_request_target:
|
|
26
|
+
types:
|
|
27
|
+
- opened
|
|
28
|
+
- edited
|
|
29
|
+
- synchronize
|
|
30
|
+
- reopened
|
|
31
|
+
merge_group:
|
|
32
|
+
|
|
33
|
+
# Default-deny; each job opts in to what it needs. The aggregator needs nothing.
|
|
34
|
+
permissions: {}
|
|
35
|
+
|
|
36
|
+
jobs:
|
|
37
|
+
conventional-title:
|
|
38
|
+
name: Validate PR title (Conventional Commits)
|
|
39
|
+
if: github.event_name == 'pull_request_target'
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
permissions:
|
|
42
|
+
pull-requests: read # read the PR title
|
|
43
|
+
statuses: write # post the lint result as a commit status
|
|
44
|
+
steps:
|
|
45
|
+
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
|
46
|
+
env:
|
|
47
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
48
|
+
with:
|
|
49
|
+
types: |
|
|
50
|
+
feat
|
|
51
|
+
fix
|
|
52
|
+
chore
|
|
53
|
+
docs
|
|
54
|
+
refactor
|
|
55
|
+
test
|
|
56
|
+
ci
|
|
57
|
+
build
|
|
58
|
+
perf
|
|
59
|
+
style
|
|
60
|
+
revert
|
|
61
|
+
requireScope: false
|
|
62
|
+
subjectPattern: ^[a-z].+[^.]$
|
|
63
|
+
subjectPatternError: |
|
|
64
|
+
Subject must start with lowercase letter and not end with a period.
|
|
65
|
+
Got: "{subject}"
|
|
66
|
+
wip: true
|
|
67
|
+
|
|
68
|
+
# Aggregator job: a single, stable check-run name to use as the required
|
|
69
|
+
# status check in branch protection, independent of the underlying job name.
|
|
70
|
+
pr-title-lint:
|
|
71
|
+
name: PR Title Lint
|
|
72
|
+
needs: [conventional-title]
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
if: always()
|
|
75
|
+
steps:
|
|
76
|
+
- name: Verify PR title lint did not fail
|
|
77
|
+
run: |
|
|
78
|
+
# Fail only on failure/cancelled; a skipped run is allowed.
|
|
79
|
+
result="${{ needs.conventional-title.result }}"
|
|
80
|
+
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
|
81
|
+
echo "PR title lint did not pass: $result"
|
|
82
|
+
exit 1
|
|
83
|
+
fi
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
merge_group:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
env:
|
|
11
|
+
UV_VERSION: "0.11.6"
|
|
12
|
+
UV_PYTHON_PREFERENCE: "managed"
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
quality-checks:
|
|
16
|
+
name: Quality Checks
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- name: Checkout code
|
|
20
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
21
|
+
|
|
22
|
+
- name: Install uv
|
|
23
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
24
|
+
with:
|
|
25
|
+
version: ${{ env.UV_VERSION }}
|
|
26
|
+
enable-cache: true
|
|
27
|
+
|
|
28
|
+
- name: Set up Python
|
|
29
|
+
run: uv python install 3.14
|
|
30
|
+
|
|
31
|
+
- name: Ensure uv lockfile is up-to-date
|
|
32
|
+
run: uv lock --check
|
|
33
|
+
|
|
34
|
+
- name: Install project dependencies
|
|
35
|
+
run: uv sync --all-groups
|
|
36
|
+
|
|
37
|
+
- name: Check formatting
|
|
38
|
+
run: uv run tox -e format-check
|
|
39
|
+
|
|
40
|
+
- name: Linting
|
|
41
|
+
run: uv run tox -e lints
|
|
42
|
+
|
|
43
|
+
- name: Run type checking
|
|
44
|
+
run: uv run tox -e typecheck
|
|
45
|
+
|
|
46
|
+
test:
|
|
47
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
48
|
+
needs: [quality-checks]
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
strategy:
|
|
51
|
+
fail-fast: false
|
|
52
|
+
matrix:
|
|
53
|
+
python-version: ["3.12", "3.14"]
|
|
54
|
+
steps:
|
|
55
|
+
- name: Checkout code
|
|
56
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
57
|
+
|
|
58
|
+
- name: Install uv
|
|
59
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
60
|
+
with:
|
|
61
|
+
version: ${{ env.UV_VERSION }}
|
|
62
|
+
enable-cache: true
|
|
63
|
+
|
|
64
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
65
|
+
run: uv python install ${{ matrix.python-version }}
|
|
66
|
+
|
|
67
|
+
- name: Install project dependencies
|
|
68
|
+
run: uv sync --all-groups
|
|
69
|
+
|
|
70
|
+
- name: Run tests
|
|
71
|
+
run: uv run tox -e py${{ matrix.python-version }}
|
|
72
|
+
|
|
73
|
+
# Aggregator job: a single, stable check-run name to use as the required
|
|
74
|
+
# status check in branch protection. Stays valid even if the matrix or the
|
|
75
|
+
# individual job names change.
|
|
76
|
+
ci:
|
|
77
|
+
name: CI
|
|
78
|
+
needs: [quality-checks, test]
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
if: always()
|
|
81
|
+
steps:
|
|
82
|
+
- name: Verify no upstream job failed
|
|
83
|
+
run: |
|
|
84
|
+
# Fail only on failure/cancelled. A skipped job is allowed
|
|
85
|
+
for result in "${{ needs.quality-checks.result }}" "${{ needs.test.result }}"; do
|
|
86
|
+
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
|
|
87
|
+
echo "An upstream job did not pass: quality-checks=${{ needs.quality-checks.result }} test=${{ needs.test.result }}"
|
|
88
|
+
exit 1
|
|
89
|
+
fi
|
|
90
|
+
done
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
ref:
|
|
7
|
+
description: "Git ref to build and publish"
|
|
8
|
+
type: string
|
|
9
|
+
required: false
|
|
10
|
+
workflow_call:
|
|
11
|
+
inputs:
|
|
12
|
+
ref:
|
|
13
|
+
description: "Git ref to build and publish"
|
|
14
|
+
type: string
|
|
15
|
+
required: true
|
|
16
|
+
|
|
17
|
+
env:
|
|
18
|
+
UV_VERSION: "0.11.6"
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
build-and-publish:
|
|
22
|
+
name: Build & Publish to PyPI
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
environment:
|
|
25
|
+
name: pypi
|
|
26
|
+
url: https://pypi.org/p/pdfform
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write
|
|
29
|
+
contents: read
|
|
30
|
+
|
|
31
|
+
steps:
|
|
32
|
+
- name: Checkout code
|
|
33
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
34
|
+
with:
|
|
35
|
+
ref: ${{ inputs.ref || github.ref }}
|
|
36
|
+
|
|
37
|
+
- name: Install uv
|
|
38
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
39
|
+
with:
|
|
40
|
+
version: ${{ env.UV_VERSION }}
|
|
41
|
+
enable-cache: true
|
|
42
|
+
|
|
43
|
+
- name: Set up Python
|
|
44
|
+
run: uv python install 3.14
|
|
45
|
+
|
|
46
|
+
- name: Build package
|
|
47
|
+
run: uv build --sdist --wheel --out-dir dist
|
|
48
|
+
|
|
49
|
+
- name: Publish to PyPI
|
|
50
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
51
|
+
with:
|
|
52
|
+
packages-dir: dist
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
name: Release Please
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
pull-requests: write
|
|
11
|
+
|
|
12
|
+
env:
|
|
13
|
+
UV_VERSION: "0.11.6"
|
|
14
|
+
UV_PYTHON_PREFERENCE: "managed"
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
release-please:
|
|
18
|
+
name: Release Please
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
outputs:
|
|
21
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
22
|
+
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
23
|
+
prs: ${{ steps.release.outputs.prs }}
|
|
24
|
+
steps:
|
|
25
|
+
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
|
26
|
+
id: release
|
|
27
|
+
with:
|
|
28
|
+
config-file: release-please-config.json
|
|
29
|
+
manifest-file: .release-please-manifest.json
|
|
30
|
+
|
|
31
|
+
refresh-lock:
|
|
32
|
+
name: Refresh uv.lock on release PR
|
|
33
|
+
needs: release-please
|
|
34
|
+
if: ${{ needs.release-please.outputs.prs != '' && needs.release-please.outputs.prs != '[]' }}
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
permissions:
|
|
37
|
+
contents: write
|
|
38
|
+
steps:
|
|
39
|
+
- name: Checkout release PR branch
|
|
40
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
41
|
+
with:
|
|
42
|
+
ref: ${{ fromJSON(needs.release-please.outputs.prs)[0].headBranchName }}
|
|
43
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
44
|
+
|
|
45
|
+
- name: Install uv
|
|
46
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
47
|
+
with:
|
|
48
|
+
version: ${{ env.UV_VERSION }}
|
|
49
|
+
enable-cache: true
|
|
50
|
+
|
|
51
|
+
- name: Set up Python
|
|
52
|
+
run: uv python install 3.14
|
|
53
|
+
|
|
54
|
+
- name: Refresh uv.lock
|
|
55
|
+
run: |
|
|
56
|
+
uv lock
|
|
57
|
+
if git diff --quiet uv.lock; then
|
|
58
|
+
echo "uv.lock already in sync — nothing to commit."
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
git config user.name "github-actions[bot]"
|
|
62
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
63
|
+
git add uv.lock
|
|
64
|
+
git commit -m "chore: refresh uv.lock for release"
|
|
65
|
+
git push
|
|
66
|
+
|
|
67
|
+
publish:
|
|
68
|
+
name: Publish to PyPI
|
|
69
|
+
needs: release-please
|
|
70
|
+
# Opt-in: set the repository variable PYPI_PUBLISH to "true" once trusted
|
|
71
|
+
# publishing is configured on PyPI. Without it a release just cuts a tag.
|
|
72
|
+
if: ${{ needs.release-please.outputs.release_created && vars.PYPI_PUBLISH == 'true' }}
|
|
73
|
+
permissions:
|
|
74
|
+
id-token: write
|
|
75
|
+
contents: read
|
|
76
|
+
uses: ./.github/workflows/publish.yml
|
|
77
|
+
with:
|
|
78
|
+
ref: ${{ needs.release-please.outputs.tag_name }}
|
|
79
|
+
secrets: inherit
|
pdfform-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.devcontainer/
|
|
2
|
+
.DS_Store
|
|
3
|
+
*.egg-info/
|
|
4
|
+
*.pyc
|
|
5
|
+
*.log
|
|
6
|
+
.vscode
|
|
7
|
+
.venv
|
|
8
|
+
.coverage
|
|
9
|
+
coverage.xml
|
|
10
|
+
.*.swp
|
|
11
|
+
.tox/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
data/
|
|
16
|
+
dist/
|
|
17
|
+
|
|
18
|
+
# Sample forms used for manual testing are kept out of the repository.
|
|
19
|
+
/*.pdf
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/hoeze-org/pdfform/compare/v0.1.0...v0.2.0) (2026-09-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* derive a JSON Schema from PDF forms and fill them ([4c8c660](https://github.com/hoeze-org/pdfform/commit/4c8c6602834902bb52472f776bf8c56a2c5c53f1))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Contributing to `pdfform`
|
|
2
|
+
|
|
3
|
+
## Development environment
|
|
4
|
+
|
|
5
|
+
The project uses [`uv`](https://docs.astral.sh/uv/) for dependency management.
|
|
6
|
+
|
|
7
|
+
### Initial setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Create the conda env (provides Python + uv)
|
|
11
|
+
micromamba env create -f environment-dev.yml
|
|
12
|
+
micromamba activate pdfform
|
|
13
|
+
|
|
14
|
+
# Install all dependency groups (runtime + dev + test + lint) into a uv-managed venv
|
|
15
|
+
uv sync --all-groups
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
All subsequent commands assume the env is activated and `uv` is on `PATH`.
|
|
19
|
+
|
|
20
|
+
## Running tasks
|
|
21
|
+
|
|
22
|
+
The project standardises tasks through [`tox`](https://tox.wiki/) with the `tox-uv` runner. Run any environment with:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv run tox -e <env>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Available environments (defined in `pyproject.toml`):
|
|
29
|
+
|
|
30
|
+
| Env | Purpose |
|
|
31
|
+
|-----------------|------------------------------------------|
|
|
32
|
+
| `format-check` | `ruff format --check .` |
|
|
33
|
+
| `lints` | `ruff check .` |
|
|
34
|
+
| `typecheck` | `mypy src/pdfform` |
|
|
35
|
+
| `py3.12` | Run pytest under Python 3.12 |
|
|
36
|
+
| `py3.14` | Run pytest under Python 3.14 |
|
|
37
|
+
|
|
38
|
+
Run the full matrix CI runs with:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv run tox
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Quick commands
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Format code
|
|
48
|
+
uv run ruff format .
|
|
49
|
+
|
|
50
|
+
# Lint with autofix
|
|
51
|
+
uv run ruff check --fix .
|
|
52
|
+
|
|
53
|
+
# Run tests directly (single Python)
|
|
54
|
+
uv run pytest
|
|
55
|
+
|
|
56
|
+
# Run a single test
|
|
57
|
+
uv run pytest tests/test_fill.py::test_radio_can_be_cleared -x
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Test fixtures
|
|
61
|
+
|
|
62
|
+
The test forms are built by hand in `tests/formbuilder.py` from `pypdf` primitives rather than with a form generator. That is deliberate: the cases worth testing are the ones a friendly generator smooths over, such as a check box whose on-state is `/Ja`, a radio group whose kids each carry a different on-state, a text field whose `/AP` `/N` is a stream and not a state dictionary, and hierarchical field names.
|
|
63
|
+
|
|
64
|
+
`tests/formbuilder.py` is on the test `pythonpath`, so test modules import from it directly. Fixtures wrapping it live in `tests/conftest.py`.
|
|
65
|
+
|
|
66
|
+
When adding support for a construct, add it to `build_form` rather than creating a one-off document, so the whole suite exercises it.
|
|
67
|
+
|
|
68
|
+
## Releases
|
|
69
|
+
|
|
70
|
+
Versioning and tagging are automated by [release-please](https://github.com/googleapis/release-please) (`.github/workflows/release-please.yml`). Publishing is handled by `.github/workflows/publish.yml`:
|
|
71
|
+
|
|
72
|
+
- release-please watches commits on `main` and opens/maintains a release PR that bumps `pyproject.toml` and updates `CHANGELOG.md`.
|
|
73
|
+
- Merging the release PR cuts a `vX.Y.Z` tag and a GitHub release.
|
|
74
|
+
- Publishing to PyPI is opt-in: it only runs when the repository variable `PYPI_PUBLISH` is set to `true`, and it uses trusted publishing (OIDC). It can also be triggered by hand from the Actions tab.
|
|
75
|
+
|
|
76
|
+
Use [Conventional Commits](https://www.conventionalcommits.org/) on `main` so release-please can pick the next version (`fix:` → patch, `feat:` → minor, `feat!:` / `BREAKING CHANGE:` → major).
|
pdfform-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florian R. Hölzlwimmer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
|
|
6
|
+
to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
+
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
8
|
+
|
|
9
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
12
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
13
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
14
|
+
DEALINGS IN THE SOFTWARE.
|
pdfform-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pdfform
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Derive a JSON Schema from a PDF form and fill it from the command line or as a library
|
|
5
|
+
Author-email: "Florian R. Hölzlwimmer" <hoeze-minion@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: acroform,forms,json-schema,pdf,xfa
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: click>=8.1
|
|
11
|
+
Requires-Dist: jsonschema>=4.21
|
|
12
|
+
Requires-Dist: pypdf>=5.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pdfform
|
|
16
|
+
|
|
17
|
+
Derive a JSON Schema from a PDF form, then fill the form from JSON. Usable as a command line tool or as a library.
|
|
18
|
+
|
|
19
|
+
There is no single library that reads an AcroForm, describes it as a schema, and writes values back correctly. `pypdf` and `pikepdf` give you the raw dictionaries, `pdf-lib` gives you a typed field API in JavaScript, and the mapping to JSON Schema is left to you, along with the handful of details that decide whether a filled form actually shows anything in a viewer. `pdfform` is that missing layer.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Schema extraction.** One JSON Schema property per fillable field, with types, enums, `maxLength`, defaults and descriptions.
|
|
24
|
+
- **Filling** from a JSON file, from `--set NAME=VALUE`, or from a Python dict.
|
|
25
|
+
- **Correct button handling.** Check box on-states are read per widget, not assumed to be `Yes`, and both `/V` and `/AS` are written.
|
|
26
|
+
- **Flattening** that bakes the appearance streams into the page content using the placement algorithm from the PDF spec, rather than only translating them.
|
|
27
|
+
- **XFA awareness.** Static and dynamic XFA are told apart, static XFA layers can be dropped (the `pdftk drop_xfa` equivalent), and dynamic XFA fails loudly instead of producing an empty schema.
|
|
28
|
+
- **Label inference.** Optional best-effort guessing of what a field called `Text12` actually means, from the text printed next to it.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Requires Python >= 3.12.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install pdfform
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
From a checkout, `uv sync --all-groups` sets everything up; see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
39
|
+
|
|
40
|
+
## Command line
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# What is in this form?
|
|
44
|
+
pdfform fields form.pdf
|
|
45
|
+
|
|
46
|
+
# Derive a JSON Schema
|
|
47
|
+
pdfform schema form.pdf -o schema.json
|
|
48
|
+
|
|
49
|
+
# Dump the current values as a starting point, edit, then write them back
|
|
50
|
+
pdfform values form.pdf --include-empty -o data.json
|
|
51
|
+
pdfform fill form.pdf -d data.json -o filled.pdf
|
|
52
|
+
|
|
53
|
+
# Or set a few fields directly
|
|
54
|
+
pdfform fill form.pdf --set Name=Doe --set Agreed=true --set Colour=Blue -o filled.pdf
|
|
55
|
+
|
|
56
|
+
# Produce a non-editable document
|
|
57
|
+
pdfform fill form.pdf -d data.json -o filled.pdf --flatten
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`pdfform fields` prints one line per field with its kind, page, options, current value and description:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
FIELD KIND PG DETAIL
|
|
64
|
+
Haushaltsjahr text 1 max 4 “Haushaltsjahr”
|
|
65
|
+
Grund_Probandenentgelt checkbox 1 on=Ja [Ja] “Probandenentgelt”
|
|
66
|
+
Mitarbeiter_des_Klinikums radio 1 [Nein, Ja] “Ist der Zahlungsempfänger Mitarbeiter des Klinikums?”
|
|
67
|
+
Antragsteller_Unterschrift signature 2 “Unterschrift des Antragstellers”
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Data can be checked against the derived schema before anything is written:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pdfform validate form.pdf -d data.json
|
|
74
|
+
pdfform fill form.pdf -d data.json -o filled.pdf --validate # refuses to write on a mismatch
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Other commands: `pdfform strip-xfa` removes an XFA layer, `pdfform xfa` lists or dumps the XFA packets. `pdfform --help` covers the rest.
|
|
78
|
+
|
|
79
|
+
## Library
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from pdfform import extract_form, build_schema, fill_form
|
|
83
|
+
|
|
84
|
+
info = extract_form("form.pdf")
|
|
85
|
+
schema = build_schema(info) # a JSON Schema document
|
|
86
|
+
|
|
87
|
+
fill_form("form.pdf", {"Name": "Doe", "Agreed": True}, "filled.pdf")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`fill_form` returns the resulting bytes, so the `output` argument is optional and fills can be chained. Values may be nested (`{"Address": {"Street": ...}}`) or flat with dot-separated keys (`{"Address.Street": ...}`).
|
|
91
|
+
|
|
92
|
+
Working with the field inventory directly:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
for field in info.fillable:
|
|
96
|
+
print(field.name, field.kind, field.on_states, field.describe())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`extract_form` never raises on a document that simply has no form; it returns an empty `FormInfo`. Errors that do get raised (`DynamicXfaError`, `UnknownFieldError`, `FieldValueError`) all derive from `PdfFormError`.
|
|
100
|
+
|
|
101
|
+
## How fields map to JSON Schema
|
|
102
|
+
|
|
103
|
+
| Field kind | JSON Schema |
|
|
104
|
+
|-------------|-------------|
|
|
105
|
+
| text | `{"type": "string"}`, plus `maxLength` when `/MaxLen` is set |
|
|
106
|
+
| check box | `{"type": "boolean"}` |
|
|
107
|
+
| radio group | `{"type": "string", "enum": [...on states...]}` |
|
|
108
|
+
| dropdown | `{"type": "string", "enum": [...]}`, or a free string for an editable combo box |
|
|
109
|
+
| list box | as dropdown, or an array of them when multi-select is set |
|
|
110
|
+
| push button | omitted, it holds no value |
|
|
111
|
+
| signature | omitted by default, `pdfform` cannot sign |
|
|
112
|
+
|
|
113
|
+
Read-only fields are omitted unless `--include-read-only` is given. Everything needed to write a value back is preserved under the `x-pdf` keyword, which validators ignore:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
"Grund_Probandenentgelt": {
|
|
117
|
+
"type": "boolean",
|
|
118
|
+
"description": "Probandenentgelt",
|
|
119
|
+
"x-pdf": {"field": "Grund_Probandenentgelt", "kind": "checkbox", "pages": [0], "onState": "Ja", "offState": "Off"}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Filling accepts more than the schema strictly describes: a check box takes `true`/`false` as well as its on-state name, a radio group or dropdown takes an option's display label as well as its export value, and `null` clears a field. Unknown names and out-of-range values are errors by default and warnings under `--no-strict`. Use `validate` when the data is meant to be schema-clean, since it holds it to the stricter standard.
|
|
124
|
+
|
|
125
|
+
### Compared to other tools
|
|
126
|
+
|
|
127
|
+
`PyPDFForm` also emits a JSON Schema, but it is lossy where it matters: a radio group becomes `{"type": "integer"}` addressed by option index, so the actual export values are unrecoverable, and a check box becomes a bare boolean that discards the on-state. `pdfform` puts the real on-state names in the `enum` and keeps them in `x-pdf`, so a schema plus a filled JSON document is enough to reconstruct exactly what was written. `pdfcpu form export` produces the closest thing on the CLI side but is not JSON Schema, and its checkbox handling only recognises `/Yes` as checked.
|
|
128
|
+
|
|
129
|
+
## Things that bite when filling PDF forms
|
|
130
|
+
|
|
131
|
+
These are the reasons this tool exists rather than a forty-line script.
|
|
132
|
+
|
|
133
|
+
**Check box on-states are not `/Yes`.** The checked value is whichever key appears in the widget's `/AP` `/N` dictionary, and it may be `/On`, `/1`, `/Ja`, `/x` or anything else. `pdfform` reads it per field.
|
|
134
|
+
|
|
135
|
+
**`/V` alone is not enough.** `/V` is the logical value; `/AS` selects which appearance stream gets drawn. Set only `/V` and the file is correct while the box looks empty in every viewer. For a radio group the rule applies per widget: `/V` goes on the group, every kid gets `/AS`, and only the kid whose own on-state matches is switched on.
|
|
136
|
+
|
|
137
|
+
**A button value is a name, not a string.** Written as a text string it is silently ignored.
|
|
138
|
+
|
|
139
|
+
**Appearance streams.** Without regenerating appearances, Acrobat may show a value that Preview, Chrome's viewer or a printer does not. `pdfform` both generates appearance streams for text and choice fields and sets `/NeedAppearances`, since neither is reliable alone.
|
|
140
|
+
|
|
141
|
+
**`/AP` `/N` is not always a state dictionary.** For text fields it is a single stream, whose dictionary keys are `/BBox`, `/Resources` and friends. Reading those as appearance states is the usual way to invent on-states that do not exist.
|
|
142
|
+
|
|
143
|
+
**Field names are hierarchical.** The real name is the `/T` of every ancestor joined with `.`, and only the leaf is stored on the field itself. `--nested` splits them back into nested objects. A partial name may itself contain a literal dot, so the flat name stays available under `x-pdf.field`.
|
|
144
|
+
|
|
145
|
+
**Widgets are usually not the field.** A field with one widget is normally merged into a single dictionary, but a field with several has them as `/Kids`, and the `/AP` sits on the kid. Looking only at the objects returned by a `get_fields()` style call finds no on-state for fields that visibly have check boxes.
|
|
146
|
+
|
|
147
|
+
**XFA.** Many government and banking forms store the real definition in an XML `/XFA` packet. If AcroForm fields exist alongside it the form is *static* XFA: fillable, but Acrobat may re-read the XFA data on open and discard the values, so `pdfform fill` drops the XFA layer by default. If `/Fields` is empty the form is *dynamic* XFA: script-generated, with no fixed field list, and `pdfform` says so instead of emitting an empty schema. The `template` packet is a much richer schema source than the AcroForm; `pdfform xfa --packet template` dumps it.
|
|
148
|
+
|
|
149
|
+
**Field names are often meaningless.** `Text12` and `Kontrollkästchen3` produce a schema that is structurally correct and semantically useless. The document's own `/TU` tooltip is used as the description when present. Failing that, `--infer-labels` guesses from the text printed next to each widget, including per-option labels for radio groups. It is a geometric heuristic, right most of the time and wrong some of the time, which is why it is opt-in.
|
|
150
|
+
|
|
151
|
+
## Flattening
|
|
152
|
+
|
|
153
|
+
`--flatten` draws each widget's current appearance into the page content stream and removes the form. The appearance is placed by transforming the `/BBox` by the form's `/Matrix`, taking the bounding box of the result, and mapping that onto the annotation `/Rect`, per PDF 32000-1 section 12.5.5. Translating to the rectangle corner instead, which is the common shortcut, misplaces any appearance whose `/BBox` is not at the origin.
|
|
154
|
+
|
|
155
|
+
Widgets that are hidden, or that have no appearance stream at all, are removed without being painted. That matches what a viewer shows for them.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|