Skip to content
Merged
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
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@

acroforge takes any PDF - vector or scanned - and injects real AcroForm fields at positions you specify. The result is a standards-compliant fillable PDF that renders correctly in Chrome's pdfium and Firefox's pdf.js.

Three operations:
Four operations:

| Operation | What it does |
|-----------|--------------|
| `build` | Inject interactive AcroForm fields into a flat PDF |
| `fill` | Set field values by name on a fillable PDF |
| `remove` | Delete specific fields by name (raises if a name is missing) |
| `flatten` | Bake field appearances into page content; remove interactive fields |

All three functions accept and return plain `bytes`, making them easy to compose in any pipeline.
All accept and return plain `bytes`, making them easy to compose in any pipeline.

---

Expand Down Expand Up @@ -273,6 +274,30 @@ af.build(other_pdf, af.read_fields(template_pdf))

(One `FieldSpec` per widget, with coordinates, type, name, and checkbox/radio on-states recovered. Dropdowns are reported as text. Pushbuttons are skipped.)

## Removing fields

`remove(pdf, names)` deletes specific fields by the name `read_fields` reports, so the two compose. Handy when `make_fillable` over-detects, or to strip a field before sending a form:

```python
specs = af.read_fields(pdf)
junk = [s.name for s in specs if s.type == af.FieldType.SIGNATURE]
clean = af.remove(pdf, junk) # raises ValueError if any name is missing
```

Naming a radio group removes the whole group; removing the last field leaves an empty, re-usable `/AcroForm`.

## Serializing a manifest

`detect()` returns a `FormManifest` and `read_fields()` returns `list[FieldSpec]` - both pydantic models, so store / send-to-a-UI / round-trip them with pydantic's built-ins (no extra API to learn):

```python
data = manifest.model_dump_json() # -> JSON string
manifest = FormManifest.model_validate_json(data) # -> back to a FormManifest
af.build(pdf, manifest.fields) # build from the (edited) specs
```

`(export, label)` option pairs round-trip as `[export, label]` arrays and back to tuples; generate a TypeScript type from `FormManifest.model_json_schema()`.

---

## Scope and honest limits
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "acroforge"
version = "0.3.4"
version = "0.4.0"
description = "Turn flat PDFs into real, fillable AcroForms - permissive, deterministic, zero-copyleft."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
3 changes: 2 additions & 1 deletion src/acroforge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from importlib.metadata import PackageNotFoundError, version

from .api import build, detect, fill, flatten, make_fillable, read_fields
from .api import build, detect, fill, flatten, make_fillable, read_fields, remove
from .models import FieldSpec, FieldType, FormManifest, ScannedPDFError

try:
Expand All @@ -21,4 +21,5 @@
"flatten",
"make_fillable",
"read_fields",
"remove",
]
22 changes: 21 additions & 1 deletion src/acroforge/api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from __future__ import annotations

from collections.abc import Iterable

from acroforge.detect.manifest import detect_manifest
from acroforge.engine.base import default_writer
from acroforge.models import FieldSpec, FormManifest
from acroforge.read import read_fields as _read_fields


def build(pdf: bytes, fields: list[FieldSpec]) -> bytes:
"""Inject real, fillable AcroForm fields into `pdf` at the given specs."""
"""Inject real, fillable AcroForm fields into `pdf` at the given specs.

Pass ``manifest.fields`` from a detected or deserialized ``FormManifest``.
"""
return default_writer().create_fields(pdf, fields)


Expand Down Expand Up @@ -36,6 +41,9 @@ def read_fields(pdf: bytes | str) -> list[FieldSpec]:
spec per button, sharing a name). Because these are real, registered fields
rather than geometry guesses, every spec has confidence = 1.0. This makes
the API symmetric: build(pdf, read_fields(other_pdf)).

Serialize the result with pydantic: ``FieldSpec`` / ``FormManifest`` support
``model_dump_json()`` and ``model_validate_json()``.
"""
return _read_fields(pdf)

Expand All @@ -47,3 +55,15 @@ def make_fillable(pdf: bytes) -> bytes:
"""
manifest = detect_manifest(pdf)
return build(pdf, manifest.fields)


def remove(pdf: bytes, names: str | Iterable[str]) -> bytes:
"""Remove specific AcroForm fields by fully-qualified name.

``names`` are the name or names exactly as returned by ``read_fields`` (a
duplicate radio name is deduplicated). Raises ``ValueError`` if any name is
not present (all-or-nothing). Naming a radio group removes the whole group;
naming a container (parent) field removes everything nested under it.
Leaves an empty ``/AcroForm`` if the last field is removed; strips ``/XFA``.
"""
return default_writer().remove(pdf, names)
112 changes: 111 additions & 1 deletion src/acroforge/engine/backends/reportlab_pypdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

import io
from collections import Counter, defaultdict
from typing import cast
from collections.abc import Iterable
from typing import Any, cast

from pypdf import PdfReader, PdfWriter
from pypdf.generic import (
Expand Down Expand Up @@ -263,6 +264,69 @@ def _on_state_name(field: DictionaryObject) -> str | None:
return states.pop() if len(states) == 1 else None


def _leaf_widget_idnums(node: DictionaryObject, seen: set[int]) -> set[int]:
"""Object numbers of the leaf /Widget annotations under a field node."""
ref = node.indirect_reference
nid = ref.idnum if ref is not None else None
if nid is not None:
if nid in seen:
return set()
seen.add(nid)
kids = node.get("/Kids")
if not kids:
return {nid} if (nid is not None and node.get("/Subtype") == "/Widget") else set()
out: set[int] = set()
for k in kids:
out |= _leaf_widget_idnums(cast(DictionaryObject, k.get_object()), seen)
return out


def _field_index(refs: Any, prefix: str, seen: set[int], out: dict[str, list[DictionaryObject]]) -> None:
"""Map fully-qualified field name -> field node(s) by walking the /Fields tree.

A name maps to a list because a malformed form may have two distinct subtrees
sharing one qualified name; we must collect the widgets of all of them so the
/Annots surgery matches what the /Fields surgery removes (no orphan widgets).
"""
for ref in refs:
node = cast(DictionaryObject, ref.get_object())
nr = node.indirect_reference
nid = nr.idnum if nr is not None else id(node)
if nid in seen:
continue
seen.add(nid)
t = node.get("/T")
if t is None:
continue # a widget-only kid (e.g. a radio button): not a named field
qname = f"{prefix}.{t}" if prefix else str(t)
out.setdefault(qname, []).append(node)
kids = node.get("/Kids")
if kids:
subfields = [k for k in kids if cast(DictionaryObject, k.get_object()).get("/T") is not None]
_field_index(subfields, qname, seen, out)


def _filter_fields(refs: Any, prefix: str, targets: set[str]) -> list[Any]:
"""The /Fields (or /Kids) refs to keep, dropping targets and emptied parents."""
kept: list[Any] = []
for ref in refs:
node = cast(DictionaryObject, ref.get_object())
t = node.get("/T")
qname = (f"{prefix}.{t}" if prefix else str(t)) if t is not None else prefix
if t is not None and qname in targets:
continue # drop this field and all its descendants
kids = node.get("/Kids")
if kids:
subfields = [k for k in kids if cast(DictionaryObject, k.get_object()).get("/T") is not None]
widget_kids = [k for k in kids if cast(DictionaryObject, k.get_object()).get("/T") is None]
new_kids = _filter_fields(subfields, qname, targets) + widget_kids
if not new_kids:
continue # parent emptied -> drop it too
node[NameObject("/Kids")] = ArrayObject(new_kids)
kept.append(ref)
return kept


class ReportlabPypdfWriter:
"""Writer backend: creates AcroForm fields via reportlab overlays + pypdf."""

Expand Down Expand Up @@ -506,3 +570,49 @@ def flatten(self, pdf: bytes) -> bytes:
out = io.BytesIO()
writer.write(out)
return out.getvalue()

def remove(self, pdf: bytes, names: str | Iterable[str]) -> bytes:
name_set = {names} if isinstance(names, str) else set(names)
if not name_set:
return pdf # nothing requested: no-op
reader = PdfReader(io.BytesIO(pdf))
writer = PdfWriter()
writer.append(reader)
acro_ref = writer.root_object.get("/AcroForm")
acro = cast(DictionaryObject, acro_ref.get_object()) if acro_ref is not None else None
top_fields = list(acro.get("/Fields") or []) if acro is not None else []

# Match by fully-qualified name (what read_fields returns) over the field TREE,
# not just top-level /Fields - nested/XFA field names live on parent chains.
index: dict[str, list[DictionaryObject]] = {}
_field_index(top_fields, "", set(), index)
missing = name_set - index.keys()
if missing:
raise ValueError(f"remove(): fields not found in PDF: {sorted(missing)}")

removed_widgets: set[int] = set()
for qname in name_set:
for node in index[qname]:
removed_widgets |= _leaf_widget_idnums(node, set())

# /Fields surgery: drop named fields, prune any parent left with no kids.
if acro is not None:
acro[NameObject("/Fields")] = ArrayObject(_filter_fields(top_fields, "", name_set))

# /Annots surgery: drop the removed widgets from every page (match by object id;
# radio/hierarchical kids have no /T so they cannot be matched by name here).
for page in writer.pages:
annots = page.get("/Annots")
if not annots:
continue
page[NameObject("/Annots")] = ArrayObject(
a for a in annots if getattr(a, "idnum", None) not in removed_widgets
)

# XFA would now describe fields that no longer exist; drop it (as build/flatten do).
if acro is not None and "/XFA" in acro:
del acro[NameObject("/XFA")]

out = io.BytesIO()
writer.write(out)
return out.getvalue()
2 changes: 2 additions & 0 deletions src/acroforge/engine/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from collections.abc import Iterable
from typing import Protocol, runtime_checkable

from acroforge.models import FieldSpec
Expand All @@ -10,6 +11,7 @@ class Writer(Protocol):
def create_fields(self, pdf: bytes, fields: list[FieldSpec]) -> bytes: ...
def fill(self, pdf: bytes, values: dict[str, object]) -> bytes: ...
def flatten(self, pdf: bytes) -> bytes: ...
def remove(self, pdf: bytes, names: str | Iterable[str]) -> bytes: ...


def default_writer() -> Writer:
Expand Down
Loading
Loading