Spaces:
Sleeping
Sleeping
| """PDF AcroForm field detection and extraction. | |
| Used to decide whether an uploaded PDF should be treated as a fillable form | |
| (routed to the pdf_form document type) versus a regular text PDF (routed | |
| through document_processor._process_pdf). | |
| """ | |
| import logging | |
| import re | |
| from typing import Any | |
| # PyMuPDF is an OPTIONAL dependency (AGPL-3.0), required ONLY for the PDF | |
| # form-filling feature implemented in this module. The MIT core imports fine | |
| # without it; calling these functions without PyMuPDF raises a clear error. | |
| # See requirements-optional.txt. | |
| try: | |
| import fitz # PyMuPDF — optional, AGPL-3.0 | |
| except ImportError: # pragma: no cover | |
| fitz = None | |
| logger = logging.getLogger(__name__) | |
| _PYMUPDF_MISSING = ( | |
| "PDF form features require PyMuPDF, an optional dependency. Install it with " | |
| "`pip install -r requirements-optional.txt` (note: PyMuPDF is AGPL-3.0)." | |
| ) | |
| def _require_fitz(): | |
| """Raise a clear error if the optional PyMuPDF dependency is absent.""" | |
| if fitz is None: | |
| raise RuntimeError(_PYMUPDF_MISSING) | |
| return fitz | |
| def _widget_type_names() -> dict: | |
| return { | |
| fitz.PDF_WIDGET_TYPE_UNKNOWN: "unknown", | |
| fitz.PDF_WIDGET_TYPE_BUTTON: "button", | |
| fitz.PDF_WIDGET_TYPE_CHECKBOX: "checkbox", | |
| fitz.PDF_WIDGET_TYPE_RADIOBUTTON: "radio", | |
| fitz.PDF_WIDGET_TYPE_TEXT: "text", | |
| fitz.PDF_WIDGET_TYPE_LISTBOX: "listbox", | |
| fitz.PDF_WIDGET_TYPE_COMBOBOX: "combobox", | |
| fitz.PDF_WIDGET_TYPE_SIGNATURE: "signature", | |
| } | |
| # Text widgets that are really signature placeholders. Covers DocuSign-style | |
| # "_es_:signature" and the bare "signed N" / "Signature" patterns common in | |
| # UK conveyancing forms (TA6, TA10). Uses substring match deliberately — | |
| # false positives like "assigned" are rare in form-field names. | |
| _SIGNATURE_NAME_RE = re.compile(r'sign(?:ed|ature)', re.IGNORECASE) | |
| def has_form_fields(path: str) -> bool: | |
| """Return True if the PDF looks like a *fillable form* — not just a | |
| content PDF that happens to carry a stray widget. | |
| Excel-exported PDFs (Japanese estimates, invoices, etc.) often ship with | |
| one or two orphan AcroForm widgets (a signature stamp box, a leftover | |
| text field from the source template) even when they're really | |
| content-only documents. Treating those as forms routes them through the | |
| form-fill chat prompt that ASKS the user which field to edit instead of | |
| discussing the content — which is exactly the bug we're trying to avoid. | |
| Heuristic: require at least 3 non-signature widgets. Signature-only | |
| PDFs (e.g. a contract with one sign-here box) read as content, and tiny | |
| stray-widget counts no longer hijack the chat. Genuine UK conveyancing | |
| forms (TA6, TA10) and similar carry dozens of widgets and still trip | |
| this threshold easily. | |
| """ | |
| _require_fitz() | |
| try: | |
| doc = fitz.open(path) | |
| except Exception as e: | |
| logger.warning(f"Could not open PDF {path} for form detection: {e}") | |
| return False | |
| try: | |
| non_signature_count = 0 | |
| for page in doc: | |
| for w in page.widgets() or []: | |
| if w.field_type != fitz.PDF_WIDGET_TYPE_SIGNATURE: | |
| non_signature_count += 1 | |
| if non_signature_count >= 3: | |
| return True | |
| return False | |
| finally: | |
| doc.close() | |
| def _infer_label(page: "fitz.Page", rect: "fitz.Rect", page_words: list) -> str: | |
| """Best-effort label inference from text near a widget. | |
| Strategy: prefer text immediately to the left on the same line, | |
| then text immediately above. Returns the closest non-empty match | |
| or "" if nothing useful is found. AcroForm field_label is rarely | |
| populated in real-world forms, so this fallback matters. | |
| """ | |
| candidates_left = [] | |
| candidates_above = [] | |
| line_tol = max(2.0, rect.height * 0.6) | |
| for w in page_words: | |
| wx0, wy0, wx1, wy1, text = w[0], w[1], w[2], w[3], w[4] | |
| if not text.strip(): | |
| continue | |
| # Same line, to the left | |
| if abs((wy0 + wy1) / 2 - (rect.y0 + rect.y1) / 2) < line_tol and wx1 <= rect.x0 + 1: | |
| candidates_left.append((rect.x0 - wx1, wx0, text)) | |
| # Above, horizontally overlapping | |
| elif wy1 <= rect.y0 + 1 and not (wx1 < rect.x0 or wx0 > rect.x1): | |
| candidates_above.append((rect.y0 - wy1, wx0, text)) | |
| def _join_nearest(cands, gap_limit): | |
| if not cands: | |
| return "" | |
| cands.sort(key=lambda c: (c[0], c[1])) | |
| nearest_dist = cands[0][0] | |
| if nearest_dist > gap_limit: | |
| return "" | |
| same = [c for c in cands if c[0] - nearest_dist < line_tol] | |
| same.sort(key=lambda c: c[1]) | |
| return " ".join(c[2] for c in same).strip() | |
| label = _join_nearest(candidates_left, gap_limit=200.0) | |
| if label: | |
| return label | |
| return _join_nearest(candidates_above, gap_limit=40.0) | |
| def _widget_on_state(w) -> str: | |
| try: | |
| return w.on_state() or "" | |
| except Exception: | |
| return "" | |
| def extract_fields(path: str) -> list[dict[str, Any]]: | |
| """Enumerate form fields, one entry per unique field name. | |
| Multiple checkbox widgets sharing a field name are treated as a single | |
| "choice" field whose options are each widget's on-state — that's the | |
| PDF idiom for radio-style "Included / Excluded / None" rows. | |
| Returns dicts with: name, type, label, value, options, page (1-indexed), | |
| rect (x0,y0,x1,y1) for the first widget in the group, required. | |
| """ | |
| _require_fitz() | |
| names = _widget_type_names() | |
| grouped: dict[str, dict[str, Any]] = {} | |
| order: list[str] = [] | |
| try: | |
| doc = fitz.open(path) | |
| except Exception as e: | |
| logger.error(f"Could not open PDF {path} for field extraction: {e}") | |
| return [] | |
| try: | |
| for page_index, page in enumerate(doc): | |
| widgets = page.widgets() or [] | |
| if not widgets: | |
| continue | |
| words = page.get_text("words") | |
| for w in widgets: | |
| name = w.field_name or "" | |
| if not name: | |
| continue | |
| wtype = names.get(w.field_type, "unknown") | |
| label = (getattr(w, "field_label", None) or "").strip() | |
| if not label: | |
| label = _infer_label(page, w.rect, words) | |
| value = w.field_value if w.field_value is not None else "" | |
| on_state = _widget_on_state(w) if wtype == "checkbox" else "" | |
| if name not in grouped: | |
| # AdobeSign-style signature placeholders are stored as | |
| # plain text widgets but named with `_es_:signature`. | |
| if wtype == "text" and _SIGNATURE_NAME_RE.search(name): | |
| wtype = "signature" | |
| order.append(name) | |
| grouped[name] = { | |
| "name": name, | |
| "type": wtype, | |
| "label": label, | |
| "value": value, | |
| "options": list(w.choice_values) if w.choice_values else ( | |
| [on_state] if on_state else [] | |
| ), | |
| "page": page_index + 1, | |
| "rect": [w.rect.x0, w.rect.y0, w.rect.x1, w.rect.y1], | |
| "required": bool((w.field_flags or 0) & 2), | |
| "_on_states": [on_state] if on_state else [], | |
| } | |
| else: | |
| g = grouped[name] | |
| if not g["label"] and label: | |
| g["label"] = label | |
| if value and not g["value"]: | |
| g["value"] = value | |
| if on_state and on_state not in g["_on_states"]: | |
| g["_on_states"].append(on_state) | |
| if on_state not in g["options"]: | |
| g["options"].append(on_state) | |
| # If a checkbox name appears more than once with different on-states, | |
| # promote it to a choice field. | |
| if wtype == "checkbox" and len(g["_on_states"]) > 1: | |
| g["type"] = "choice" | |
| finally: | |
| doc.close() | |
| out = [] | |
| for name in order: | |
| g = grouped[name] | |
| g.pop("_on_states", None) | |
| out.append(g) | |
| return out | |
| def stamp_signatures( | |
| pdf_path: str, | |
| output_path: str, | |
| stamps: dict[str, bytes], | |
| ) -> int: | |
| """Stamp PNG signature images into the PDF at each named field's rect. | |
| `stamps` is {field_name: png_bytes}. Each named field is found in the | |
| AcroForm; the image is drawn into the field's rectangle preserving aspect | |
| ratio. The widget itself is left intact (still a form field) so it can be | |
| re-edited later if needed; the stamp is rendered on top. | |
| Returns the number of stamps written. Pass the source PDF (or an | |
| already-filled output from fill_fields) and a fresh output_path. | |
| """ | |
| if not stamps: | |
| return 0 | |
| _require_fitz() | |
| doc = fitz.open(pdf_path) | |
| written = 0 | |
| try: | |
| for page in doc: | |
| for w in page.widgets() or []: | |
| name = w.field_name | |
| if name not in stamps: | |
| continue | |
| png = stamps[name] | |
| if not png: | |
| continue | |
| try: | |
| page.insert_image(w.rect, stream=png, keep_proportion=True, overlay=True) | |
| written += 1 | |
| except Exception as e: | |
| logger.warning(f"Failed to stamp signature into {name}: {e}") | |
| doc.save(output_path, incremental=False, deflate=True) | |
| finally: | |
| doc.close() | |
| return written | |
| def stamp_annotations( | |
| pdf_path: str, | |
| output_path: str, | |
| annotations: list[dict], | |
| signature_pngs: dict[str, bytes] | None = None, | |
| ) -> int: | |
| """Burn freeform annotations (text, check, signature) onto a PDF. | |
| Each annotation has page-percentage coords (x, y, w, h: 0–100), a `kind` | |
| in {text, check, signature}, a string value, and a line_height for text. | |
| Returns the number of annotations stamped. | |
| """ | |
| if not annotations: | |
| return 0 | |
| _require_fitz() | |
| signature_pngs = signature_pngs or {} | |
| doc = fitz.open(pdf_path) | |
| written = 0 | |
| try: | |
| for ann in annotations: | |
| try: | |
| page_no = int(ann.get("page") or 1) | |
| if page_no < 1 or page_no > doc.page_count: | |
| continue | |
| page = doc[page_no - 1] | |
| pw, ph = page.rect.width, page.rect.height | |
| x = float(ann.get("x", 0)) / 100.0 * pw | |
| y = float(ann.get("y", 0)) / 100.0 * ph | |
| w = float(ann.get("w", 0)) / 100.0 * pw | |
| h = float(ann.get("h", 0)) / 100.0 * ph | |
| rect = fitz.Rect(x, y, x + w, y + h) | |
| kind = ann.get("kind", "text") | |
| value = ann.get("value", "") | |
| if kind == "text": | |
| if not value: | |
| continue | |
| line_height = float(ann.get("line_height") or 1.3) | |
| lines = value.split("\n") | |
| # Fixed point size — keeps text consistent across boxes | |
| # regardless of how each was resized. Per HTML metrics the | |
| # baseline of a line box sits at fontsize × (lh + 0.6) / 2 | |
| # from the line-box top (half the leading above the glyph, | |
| # half below, ascent ≈ 0.8 × fontsize). | |
| fontsize = 11.0 | |
| # Stride between lines is tuned to match what the editor | |
| # shows: the editor's textarea renders text larger than | |
| # 11pt (cqh-based ≈ 1.5% of page-image height ≈ 17pt for | |
| # Letter), so its rows are spaced wider than 11 × lh on | |
| # the page. Multiply the export stride to compensate. | |
| line_box = fontsize * line_height * 1.2 | |
| # First baseline at one ascent below the box top — closest | |
| # match to where the editor's first line of text appears. | |
| yy = y + fontsize * 0.85 | |
| # Match the textarea's 4px left padding (~3 PDF points). | |
| xx = x + 3.0 | |
| for line in lines: | |
| try: | |
| page.insert_text( | |
| (xx, yy), | |
| line, | |
| fontsize=fontsize, | |
| color=(0, 0, 0), | |
| ) | |
| except Exception as e: | |
| logger.warning(f"insert_text failed for annotation: {e}") | |
| yy += line_box | |
| written += 1 | |
| elif kind == "check": | |
| # Draw a checkmark stroke that fills the box. | |
| cx = x + w / 2.0 | |
| cy = y + h / 2.0 | |
| size = min(w, h) * 0.85 | |
| p1 = fitz.Point(cx - size * 0.40, cy + size * 0.05) | |
| p2 = fitz.Point(cx - size * 0.10, cy + size * 0.30) | |
| p3 = fitz.Point(cx + size * 0.45, cy - size * 0.30) | |
| shape = page.new_shape() | |
| shape.draw_polyline([p1, p2, p3]) | |
| shape.finish( | |
| color=(0, 0, 0), | |
| width=max(1.0, size * 0.13), | |
| lineCap=1, | |
| lineJoin=1, | |
| ) | |
| shape.commit() | |
| written += 1 | |
| elif kind == "signature": | |
| if not isinstance(value, str) or not value.startswith("signature:"): | |
| continue | |
| sid = value[len("signature:"):].strip() | |
| png = signature_pngs.get(sid) | |
| if not png: | |
| continue | |
| try: | |
| page.insert_image(rect, stream=png, keep_proportion=True, overlay=True) | |
| written += 1 | |
| except Exception as e: | |
| logger.warning(f"signature stamp failed: {e}") | |
| except Exception as e: | |
| logger.warning(f"Failed to stamp annotation {ann.get('id')}: {e}") | |
| continue | |
| doc.save(output_path, incremental=False, deflate=True) | |
| finally: | |
| doc.close() | |
| return written | |
| def fill_fields(source_path: str, output_path: str, values: dict[str, Any]) -> int: | |
| """Write values back into the AcroForm and save a new PDF. | |
| Returns the number of fields updated. Unknown field names are ignored. | |
| Layout of the source PDF is preserved. | |
| """ | |
| _require_fitz() | |
| doc = fitz.open(source_path) | |
| updated = 0 | |
| try: | |
| for page in doc: | |
| for w in page.widgets() or []: | |
| name = w.field_name | |
| if name not in values: | |
| continue | |
| new_value = values[name] | |
| if w.field_type == fitz.PDF_WIDGET_TYPE_CHECKBOX: | |
| on_state = _widget_on_state(w) | |
| if isinstance(new_value, bool): | |
| # Single checkbox: bool semantics | |
| w.field_value = (on_state or "Yes") if new_value else "Off" | |
| else: | |
| # Choice/radio group: only the widget whose on_state matches | |
| # gets that on_state; the rest go Off. | |
| chosen = "" if new_value is None else str(new_value).strip() | |
| w.field_value = on_state if on_state and on_state == chosen else "Off" | |
| else: | |
| w.field_value = "" if new_value is None else str(new_value) | |
| w.update() | |
| updated += 1 | |
| doc.save(output_path, incremental=False, deflate=True) | |
| finally: | |
| doc.close() | |
| return updated | |