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
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.3"
version = "0.3.4"
description = "Turn flat PDFs into real, fillable AcroForms - permissive, deterministic, zero-copyleft."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
29 changes: 24 additions & 5 deletions src/acroforge/detect/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
16 changes: 10 additions & 6 deletions src/acroforge/detect/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
25 changes: 24 additions & 1 deletion tests/test_detect_robustness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading