diff --git a/README.md b/README.md index 4d422db..9870f43 100644 --- a/README.md +++ b/README.md @@ -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. --- @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 7c3f9bb..99353ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/acroforge/__init__.py b/src/acroforge/__init__.py index 4df4736..e037232 100644 --- a/src/acroforge/__init__.py +++ b/src/acroforge/__init__.py @@ -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: @@ -21,4 +21,5 @@ "flatten", "make_fillable", "read_fields", + "remove", ] diff --git a/src/acroforge/api.py b/src/acroforge/api.py index f3400e7..850e809 100644 --- a/src/acroforge/api.py +++ b/src/acroforge/api.py @@ -1,5 +1,7 @@ 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 @@ -7,7 +9,10 @@ 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) @@ -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) @@ -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) diff --git a/src/acroforge/engine/backends/reportlab_pypdf.py b/src/acroforge/engine/backends/reportlab_pypdf.py index dce7aeb..712459f 100644 --- a/src/acroforge/engine/backends/reportlab_pypdf.py +++ b/src/acroforge/engine/backends/reportlab_pypdf.py @@ -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 ( @@ -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.""" @@ -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() diff --git a/src/acroforge/engine/base.py b/src/acroforge/engine/base.py index e7ab9f8..f3f67c4 100644 --- a/src/acroforge/engine/base.py +++ b/src/acroforge/engine/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Protocol, runtime_checkable from acroforge.models import FieldSpec @@ -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: diff --git a/tests/test_remove.py b/tests/test_remove.py new file mode 100644 index 0000000..dcf3e10 --- /dev/null +++ b/tests/test_remove.py @@ -0,0 +1,220 @@ +import io +import pathlib + +import pypdf +import pytest + +import acroforge as af +from acroforge.models import FieldSpec, FieldType +from tests.test_engine_text_checkbox import _blank_pdf + + +def _two_field_pdf(): + fields = [ + FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 700, 300, 718), name="keep"), + FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 660, 300, 678), name="drop"), + ] + return af.build(_blank_pdf(), fields) + + +def _names(pdf): + return {s.name for s in af.read_fields(pdf)} + + +# --- Task 1: API surface ---------------------------------------------------- + +def test_remove_is_exported_and_callable(): + assert callable(af.remove) + + +# --- Task 2: flat fields ---------------------------------------------------- + +def test_remove_flat_field_drops_only_that_field(): + out = af.remove(_two_field_pdf(), "drop") + assert _names(out) == {"keep"} + fields = pypdf.PdfReader(io.BytesIO(out)).get_fields() or {} + assert "drop" not in fields and "keep" in fields + + +def test_remove_accepts_iterable(): + fields = [ + FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 700, 300, 718), name="a"), + FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 660, 300, 678), name="b"), + FieldSpec(type=FieldType.CHECKBOX, page=0, rect=(100, 620, 114, 634), name="c"), + ] + out = af.remove(af.build(_blank_pdf(), fields), ["a", "c"]) + assert _names(out) == {"b"} + + +def test_remove_result_is_valid_pdf_and_rereadable(): + out = af.remove(_two_field_pdf(), "drop") + assert out[:5] == b"%PDF-" + af.read_fields(out) # must not raise + + +# --- Task 3: radio, hierarchical, parent pruning ---------------------------- + +def _radio_pdf(): + fields = [ + FieldSpec(type=FieldType.RADIO, page=0, rect=(100, 700, 114, 714), name="sex", export_value="M"), + FieldSpec(type=FieldType.RADIO, page=0, rect=(140, 700, 154, 714), name="sex", export_value="F"), + FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 660, 300, 678), name="other"), + ] + return af.build(_blank_pdf(), fields) + + +def test_remove_radio_group_dedups_and_drops_whole_group(): + pdf = _radio_pdf() + radio_names = [s.name for s in af.read_fields(pdf) if s.type == FieldType.RADIO] # ["sex","sex"] + out = af.remove(pdf, radio_names) + assert "sex" not in _names(out) + assert "other" in _names(out) + + +def _hierarchical_pdf(): + from pypdf import PdfWriter + from pypdf.generic import ( + ArrayObject, + DictionaryObject, + FloatObject, + NameObject, + TextStringObject, + ) + + w = PdfWriter() + w.add_blank_page(width=612, height=792) + page = w.pages[0] + parent = DictionaryObject() + parent[NameObject("/FT")] = NameObject("/Tx") + parent[NameObject("/T")] = TextStringObject("section") + pref = w._add_object(parent) + kid_refs = [] + for i, leaf in enumerate(("a", "b")): + kid = DictionaryObject() + kid[NameObject("/Type")] = NameObject("/Annot") + kid[NameObject("/Subtype")] = NameObject("/Widget") + kid[NameObject("/T")] = TextStringObject(leaf) + kid[NameObject("/Rect")] = ArrayObject( + [FloatObject(c) for c in (50, 700 - i * 40, 250, 718 - i * 40)]) + kid[NameObject("/P")] = page.indirect_reference + kid[NameObject("/Parent")] = pref + kid_refs.append(w._add_object(kid)) + parent[NameObject("/Kids")] = ArrayObject(kid_refs) + page[NameObject("/Annots")] = ArrayObject(kid_refs) + acro = DictionaryObject() + acro[NameObject("/Fields")] = ArrayObject([pref]) + w.root_object[NameObject("/AcroForm")] = w._add_object(acro) + buf = io.BytesIO() + w.write(buf) + return buf.getvalue() + + +def test_remove_hierarchical_leaf_keeps_sibling(): + pdf = _hierarchical_pdf() + assert _names(pdf) == {"section.a", "section.b"} + out = af.remove(pdf, "section.a") + assert _names(out) == {"section.b"} + + +def test_remove_last_child_prunes_empty_parent(): + out = af.remove(_hierarchical_pdf(), ["section.a", "section.b"]) + assert _names(out) == set() + acro = pypdf.PdfReader(io.BytesIO(out)).trailer["/Root"]["/AcroForm"].get_object() + assert len(acro.get("/Fields") or []) == 0 + + +def test_remove_duplicate_qualified_name_leaves_no_orphan_widget(): + # two distinct top-level fields share the qualified name "dup": removing it must + # drop BOTH from /Fields AND both widgets from /Annots (no orphan left behind). + from pypdf import PdfWriter + from pypdf.generic import ( + ArrayObject, + DictionaryObject, + FloatObject, + NameObject, + TextStringObject, + ) + + w = PdfWriter() + w.add_blank_page(width=612, height=792) + page = w.pages[0] + refs = [] + for i in range(2): + d = DictionaryObject() + d[NameObject("/Type")] = NameObject("/Annot") + d[NameObject("/Subtype")] = NameObject("/Widget") + d[NameObject("/FT")] = NameObject("/Tx") + d[NameObject("/T")] = TextStringObject("dup") + d[NameObject("/Rect")] = ArrayObject( + [FloatObject(c) for c in (50, 700 - i * 40, 250, 718 - i * 40)]) + d[NameObject("/P")] = page.indirect_reference + refs.append(w._add_object(d)) + page[NameObject("/Annots")] = ArrayObject(refs) + acro = DictionaryObject() + acro[NameObject("/Fields")] = ArrayObject(refs) + w.root_object[NameObject("/AcroForm")] = w._add_object(acro) + buf = io.BytesIO() + w.write(buf) + + out = af.remove(buf.getvalue(), "dup") + r = pypdf.PdfReader(io.BytesIO(out)) + acro = r.trailer["/Root"]["/AcroForm"].get_object() + assert len(acro.get("/Fields") or []) == 0 + annots = r.pages[0].get("/Annots") or [] + assert [a for a in annots if a.get_object().get("/Subtype") == "/Widget"] == [] + + +# --- Task 4: edge cases ----------------------------------------------------- + +def test_remove_missing_name_raises_all_or_nothing(): + pdf = _two_field_pdf() + with pytest.raises(ValueError, match="not found"): + af.remove(pdf, ["keep", "nope"]) + assert _names(pdf) == {"keep", "drop"} # input bytes unchanged + + +def test_remove_on_pdf_without_acroform_raises(): + with pytest.raises(ValueError, match="not found"): + af.remove(_blank_pdf(), "anything") + + +def test_remove_all_leaves_empty_acroform_not_deleted(): + out = af.remove(_two_field_pdf(), ["keep", "drop"]) + assert "/AcroForm" in pypdf.PdfReader(io.BytesIO(out)).trailer["/Root"] + assert af.read_fields(out) == [] + rebuilt = af.build(out, [FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 700, 300, 718), name="new")]) + assert _names(rebuilt) == {"new"} + + +def test_remove_empty_names_is_noop(): + assert _names(af.remove(_two_field_pdf(), [])) == {"keep", "drop"} + + +# --- Task 5: cross-viewer + real form --------------------------------------- + +def test_remove_survivors_render_in_pdfium(tmp_path): + from harness.diff import png_mismatch_ratio + from harness.render_pdfium import render_pdfium + + fields = [ + FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 700, 300, 718), name="keep"), + FieldSpec(type=FieldType.TEXT, page=0, rect=(100, 660, 300, 678), name="drop"), + ] + filled = af.fill(af.build(_blank_pdf(), fields), {"keep": "VISIBLE", "drop": "GONE"}) + final = af.flatten(af.remove(filled, "drop")) + base = tmp_path / "b.pdf" + base.write_bytes(_blank_pdf()) + doc = tmp_path / "d.pdf" + doc.write_bytes(final) + a = render_pdfium(str(base), tmp_path / "b.png", scale=2.0) + b = render_pdfium(str(doc), tmp_path / "d.png", scale=2.0) + assert png_mismatch_ratio(a, b) > 0.0 # surviving "keep" field still renders + + +def test_remove_on_real_w9_fixture(): + w9 = (pathlib.Path(__file__).parent / "fixtures" / "fw9.pdf").read_bytes() + before = {s.name for s in af.read_fields(w9)} + victim = sorted(before)[0] # a real fully-qualified field name + after = {s.name for s in af.read_fields(af.remove(w9, victim))} + assert victim not in after + assert after == before - {victim} diff --git a/uv.lock b/uv.lock index 3c82c59..d5e2bf7 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "acroforge" -version = "0.3.4" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "pdfplumber" },