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.
@@ -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
@@ -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,3 @@
1
+ {
2
+ ".": "0.2.0"
3
+ }
@@ -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