File size: 7,548 Bytes
968fcad
 
 
 
856e295
968fcad
 
856e295
968fcad
 
856e295
 
ee70a30
856e295
ee70a30
 
856e295
ee70a30
856e295
 
ee70a30
 
 
856e295
 
968fcad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856e295
968fcad
856e295
968fcad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856e295
 
 
 
 
 
 
 
 
 
 
 
 
5f3cfc2
34cbeee
 
856e295
8ebf279
856e295
8ebf279
856e295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968fcad
856e295
968fcad
856e295
968fcad
 
856e295
968fcad
 
 
856e295
968fcad
856e295
 
 
 
 
 
 
 
 
 
968fcad
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""
Core PDF field detection logic.
  1. AcroForm PDF      β†’ extract native widgets
  2. Flat vector PDF   β†’ extract from drawings layer (rectangles, lines)
  3. Image/scanned PDF β†’ run FFDNet (commonforms) for checkbox/text detection
"""
import fitz
import tempfile, os
from typing import Literal

# FFDNet via commonforms β€” loaded lazily on first use
_ffdnet_ready = False
_ffdnet_error = ''
def _ensure_ffdnet():
    global _ffdnet_ready, _ffdnet_error
    if not _ffdnet_ready and not _ffdnet_error:
        try:
            import commonforms as _cf
            _ffdnet_ready = True
        except Exception as e:
            import traceback
            _ffdnet_error = traceback.format_exc()
            print(f"commonforms import failed:\n{_ffdnet_error}")
    return _ffdnet_ready


FieldType = Literal['checkbox', 'text', 'signature']


def _label_near(x0, y0, x1, y1, text_spans):
    """Find the nearest text label above or left of a rect."""
    best, best_dist = '', 1e9
    for span in text_spans:
        sx0, sy0, sx1, sy1 = span['bbox']
        # Candidate: text ends to the left, or is above (within 20pt)
        if sx1 <= x0 + 5 and abs((sy0 + sy1) / 2 - (y0 + y1) / 2) < 20:
            dist = x0 - sx1
            if 0 <= dist < best_dist:
                best, best_dist = span['text'], dist
        elif sy1 <= y0 + 2 and sx0 >= x0 - 5 and sx1 <= x1 + 5:
            dist = y0 - sy1
            if 0 <= dist < best_dist:
                best, best_dist = span['text'], dist
    return best.strip()


def detect_page(page) -> dict:
    pw, ph = page.rect.width, page.rect.height
    widgets  = list(page.widgets())
    drawings = page.get_drawings()
    images   = page.get_images(full=False)

    # ── Case 1: AcroForm ────────────────────────────────────────────────────
    if widgets:
        boxes = []
        for w in widgets:
            r = w.rect
            ftype: FieldType
            if w.field_type in (fitz.PDF_WIDGET_TYPE_CHECKBOX, fitz.PDF_WIDGET_TYPE_RADIOBUTTON):
                ftype = 'checkbox'
            elif w.field_type == fitz.PDF_WIDGET_TYPE_SIGNATURE:
                ftype = 'signature'
            else:
                ftype = 'text'
            boxes.append({
                'type': ftype,
                'x': r.x0 / pw, 'y': (ph - r.y1) / ph,
                'w': r.width  / pw, 'h': r.height / ph,
                'label': w.field_name or '',
                'source': 'acroform',
            })
        return {'source': 'acroform', 'boxes': boxes}

    # ── Case 3: Image/scanned β€” run FFDNet ───────────────────────────────
    if not drawings:
        return {'source': 'needs_ml', 'boxes': []}  # resolved in detect_pdf

    # ── Case 2: Flat vector PDF ────────────────────────────────────────────
    text_spans = []
    for block in page.get_text('dict', flags=fitz.TEXT_INHIBIT_SPACES).get('blocks', []):
        for line in block.get('lines', []):
            for span in line.get('spans', []):
                t = span.get('text', '').strip()
                if t:
                    text_spans.append({'text': t, 'bbox': span['bbox']})

    boxes = []
    seen  = set()

    for d in drawings:
        r = d['rect']
        w, h = r.width, r.height
        if w < 1 or h < 1:
            continue

        key = (round(r.x0), round(r.y0))
        if key in seen:
            continue
        seen.add(key)

        x_frac = r.x0 / pw
        y_frac = r.y0 / ph
        w_frac = w / pw
        h_frac = h / ph

        # Small square β†’ checkbox / radio
        if abs(w - h) < w * 0.35 and 3 < w < 22:
            label = _label_near(r.x0, r.y0, r.x1, r.y1, text_spans)
            boxes.append({
                'type': 'checkbox', 'source': 'vector',
                'x': x_frac, 'y': y_frac, 'w': w_frac, 'h': h_frac,
                'label': label,
            })

        # Thin horizontal line β†’ text underline input
        elif h < 2.5 and w > 20:
            label = _label_near(r.x0, r.y0, r.x1, r.y1, text_spans)
            pad   = min(14 / ph, 0.02)
            boxes.append({
                'type': 'text', 'source': 'vector_line',
                'x': x_frac, 'y': max(0, y_frac - pad),
                'w': w_frac, 'h': pad + h_frac + 1 / ph,
                'label': label,
            })

        # Rectangular box wider than tall β†’ text input field
        elif w > h * 1.2 and h > 6 and w < pw * 0.95:
            label = _label_near(r.x0, r.y0, r.x1, r.y1, text_spans)
            boxes.append({
                'type': 'text', 'source': 'vector_rect',
                'x': x_frac, 'y': y_frac, 'w': w_frac, 'h': h_frac,
                'label': label,
            })

    return {'source': 'vector', 'boxes': boxes}


def _run_ffdnet(pdf_bytes: bytes, page_nums: list[int]) -> dict[int, list[dict]]:
    """Run commonforms FFDNet on specific pages, return boxes per page index."""
    if not _ensure_ffdnet():
        return {}
    from commonforms import prepare_form

    with tempfile.TemporaryDirectory() as tmp:
        in_path  = os.path.join(tmp, 'in.pdf')
        out_path = os.path.join(tmp, 'out.pdf')
        with open(in_path, 'wb') as f:
            f.write(pdf_bytes)

        try:
            prepare_form(in_path, out_path, confidence=0.1, device='cpu')
            out_size = os.path.getsize(out_path) if os.path.exists(out_path) else 0
            print(f"FFDNet ran OK, output size={out_size} bytes")
        except Exception as e:
            import traceback
            print(f"FFDNet error: {e}")
            traceback.print_exc()
            return {}

        out_doc = fitz.open(out_path)
        results: dict[int, list[dict]] = {}
        for page_num in page_nums:
            if page_num >= len(out_doc):
                continue
            page = out_doc[page_num]
            pw, ph = page.rect.width, page.rect.height
            boxes = []
            for w in page.widgets():
                r = w.rect
                ftype = 'checkbox' if w.field_type in (
                    fitz.PDF_WIDGET_TYPE_CHECKBOX,
                    fitz.PDF_WIDGET_TYPE_RADIOBUTTON,
                ) else 'text'
                boxes.append({
                    'type': ftype,
                    'x': r.x0 / pw, 'y': r.y0 / ph,
                    'w': r.width  / pw, 'h': r.height / ph,
                    'label': w.field_name or '',
                    'source': 'ffdnet',
                })
            results[page_num] = boxes
        out_doc.close()
    return results


def detect_pdf(pdf_bytes: bytes) -> list[dict]:
    doc   = fitz.open(stream=pdf_bytes, filetype='pdf')
    pages = []

    for page_num, page in enumerate(doc):
        result = detect_page(page)
        result['page']   = page_num
        result['width']  = page.rect.width
        result['height'] = page.rect.height
        pages.append(result)

    doc.close()

    # Run FFDNet on any image pages in one pass (model loaded once)
    ml_pages = [p['page'] for p in pages if p['source'] == 'needs_ml']
    if ml_pages:
        ffdnet_results = _run_ffdnet(pdf_bytes, ml_pages)
        for p in pages:
            if p['source'] == 'needs_ml':
                p['source'] = 'ffdnet'
                p['boxes']  = ffdnet_results.get(p['page'], [])

    return pages