From 97752fdacd2b0e2484f57a0a052750e32c5d4a0a Mon Sep 17 00:00:00 2001 From: Sanjay Date: Mon, 8 Jun 2026 20:51:41 +0000 Subject: [PATCH] improve(detect): tighten underline detector for precision (best-effort) Real-corpus QA showed make_fillable detection precision at 0.58 (42% spurious), with find_underlines the bottleneck. Reject near-full-width separator rules and top/bottom-margin header/footer rules, merge near-coincident duplicate lines, and dedup table cells against underlines with a 6pt tolerance. On the 104-form real corpus: precision 0.58 -> 0.61, false positives -12% (3719 -> 3259), recall held. Best-effort path (measured, not gated). 3 regression tests added. Bumps to 0.3.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- src/acroforge/detect/geometry.py | 29 ++++++++++++++++++++++++----- src/acroforge/detect/manifest.py | 16 ++++++++++------ tests/test_detect_robustness.py | 25 ++++++++++++++++++++++++- uv.lock | 2 +- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9591035..7c3f9bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "acroforge" -version = "0.3.3" +version = "0.3.4" description = "Turn flat PDFs into real, fillable AcroForms - permissive, deterministic, zero-copyleft." readme = "README.md" requires-python = ">=3.11" diff --git a/src/acroforge/detect/geometry.py b/src/acroforge/detect/geometry.py index 72b487e..b16c2d5 100644 --- a/src/acroforge/detect/geometry.py +++ b/src/acroforge/detect/geometry.py @@ -19,6 +19,12 @@ class Candidate: _MAX_RULE_RECT_H = 2.0 _H_TOL = 1.5 _V_TOL = 1.5 +# Precision filters for find_underlines (best-effort): a near-full-width rule is a +# decorative separator, not a write-on field; rules in the top/bottom margin are +# header/footer separators; near-coincident lines are the same field drawn twice. +_MAX_UNDERLINE_FRAC = 0.85 +_MARGIN_FRAC = 0.04 +_DUP_Y_TOL = 3.0 def _xy(obj: dict[str, Any]) -> tuple[float, float, float, float] | None: @@ -103,13 +109,14 @@ def _end_near_vertical( def find_underlines(page: Any) -> list[Candidate]: out: list[Candidate] = [] + pw, ph = float(page.width), float(page.height) segs = ( list(page.lines) + [e for e in page.edges if e.get("orientation") == "h"] + _rule_rects(page) ) verts = _vertical_segments(page) - seen: set[tuple[int, int, int]] = set() + emitted: list[tuple[float, float, float]] = [] # (x0, x1, y) of kept underlines for s in segs: c = _xy(s) if c is None: @@ -118,16 +125,28 @@ def find_underlines(page: Any) -> list[Candidate]: if abs(y1 - y0) > _H_TOL: # not horizontal continue y = y0 - if (x1 - x0) < _MIN_UNDERLINE_W: + w = x1 - x0 + if w < _MIN_UNDERLINE_W: + continue + # A near-full-width rule is a decorative separator, not a write-on field. + if w > _MAX_UNDERLINE_FRAC * pw: + continue + # Rules in the top/bottom page margin are header/footer separators. + if y > (1.0 - _MARGIN_FRAC) * ph or y < _MARGIN_FRAC * ph: continue # A write-on underline has OPEN ends; a table/box edge is bounded by # vertical rules at BOTH ends. Skip the latter. if _end_near_vertical(x0, y, verts) and _end_near_vertical(x1, y, verts): continue - key = (round(x0), round(x1), round(y)) - if key in seen: + # Tolerant dedup: merge near-coincident lines (a line plus its rule-rect or + # h-edge) so one field is not detected twice or three times. + if any( + abs(ey - y) <= _DUP_Y_TOL + and (min(x1, ex1) - max(x0, ex0)) > 0.7 * min(w, ex1 - ex0) + for ex0, ex1, ey in emitted + ): continue - seen.add(key) + emitted.append((x0, x1, y)) out.append(Candidate("text", (x0, y, x1, y + _FIELD_HEIGHT), 0.6)) return out diff --git a/src/acroforge/detect/manifest.py b/src/acroforge/detect/manifest.py index 13705eb..b97a624 100644 --- a/src/acroforge/detect/manifest.py +++ b/src/acroforge/detect/manifest.py @@ -45,12 +45,17 @@ def _detect_page(page: Any, pno: int) -> list[FieldSpec]: words = _words_bottom_up(page) fields: list[FieldSpec] = [] - # Track rounded positions of text fields to dedup table cells vs underlines. - text_positions: set[tuple[int, int]] = set() + # Track text-field positions to dedup table cells against underlines, with a + # small tolerance so an underline and a near-coincident table-cell edge that + # describe the same field are not both emitted as separate guesses. + text_positions: list[tuple[float, float]] = [] + + def _near(x: float, y: float, tol: float = 6.0) -> bool: + return any(abs(px - x) <= tol and abs(py - y) <= tol for px, py in text_positions) for i, cand in enumerate(find_underlines(page)): name = name_for(cand.rect, words, fallback=f"text_{pno}_{i}") - text_positions.add((round(cand.rect[0]), round(cand.rect[1]))) + text_positions.append((cand.rect[0], cand.rect[1])) fields.append( FieldSpec( type=FieldType.TEXT, page=pno, rect=cand.rect, name=name, @@ -59,10 +64,9 @@ def _detect_page(page: Any, pno: int) -> list[FieldSpec]: ) for ci, (cand, label) in enumerate(find_table_cells(page)): - pos = (round(cand.rect[0]), round(cand.rect[1])) - if pos in text_positions: + if _near(cand.rect[0], cand.rect[1]): continue # already detected at ~this position (e.g. an underline) - text_positions.add(pos) + text_positions.append((cand.rect[0], cand.rect[1])) name = label or name_for(cand.rect, words, fallback=f"cell_{pno}_{ci}") fields.append( FieldSpec( diff --git a/tests/test_detect_robustness.py b/tests/test_detect_robustness.py index f1e149e..3404a23 100644 --- a/tests/test_detect_robustness.py +++ b/tests/test_detect_robustness.py @@ -4,10 +4,12 @@ class _FakePage: - def __init__(self, lines=None, edges=None, rects=None): + def __init__(self, lines=None, edges=None, rects=None, width=612, height=792): self.lines = lines or [] self.edges = edges or [] self.rects = rects or [] + self.width = width + self.height = height def test_find_underlines_skips_objects_missing_coords(): @@ -20,6 +22,27 @@ def test_find_underlines_skips_objects_missing_coords(): assert len(cands) == 1 # valid underline still found +def test_find_underlines_rejects_full_width_separator(): + # a near-full-width horizontal rule is a decorative separator, not a field + page = _FakePage(lines=[{"x0": 10, "x1": 600, "y0": 400, "y1": 400}]) # ~96% of 612pt + assert find_underlines(page) == [] + + +def test_find_underlines_rejects_margin_rule(): + # a rule in the top page margin is a header separator, not a field + page = _FakePage(lines=[{"x0": 100, "x1": 300, "y0": 785, "y1": 785}]) # top 4% of 792pt + assert find_underlines(page) == [] + + +def test_find_underlines_dedups_near_coincident_lines(): + # a line plus its near-coincident edge (same field drawn twice) -> one candidate + page = _FakePage( + lines=[{"x0": 100, "x1": 300, "y0": 400, "y1": 400}], + edges=[{"orientation": "h", "x0": 100, "x1": 300, "y0": 401, "y1": 401}], + ) + assert len(find_underlines(page)) == 1 + + def test_vertical_segments_skips_objects_missing_coords(): page = _FakePage(edges=[{"orientation": "v"}], rects=[{"x0": 1}], lines=[{}]) assert _vertical_segments(page) == [] # no crash, nothing usable diff --git a/uv.lock b/uv.lock index fc9cf1e..3c82c59 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "acroforge" -version = "0.3.3" +version = "0.3.4" source = { editable = "." } dependencies = [ { name = "pdfplumber" },