Spaces:
Sleeping
Sleeping
| # ββ Fix Python 3.13 asyncio bug βββββββββββββββββββββββββββββββββββββββββββββββ | |
| import asyncio.base_events | |
| _orig_del = asyncio.base_events.BaseEventLoop.__del__ | |
| def _patched_del(self): | |
| try: _orig_del(self) | |
| except Exception: pass | |
| asyncio.base_events.BaseEventLoop.__del__ = _patched_del | |
| # ββ Standard library ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import warnings, logging, os, json, re, base64, copy, hashlib | |
| from io import BytesIO | |
| warnings.filterwarnings("ignore") | |
| os.environ["TOKENIZERS_PARALLELISM"] = "false" | |
| logging.getLogger("transformers").setLevel(logging.ERROR) | |
| logging.getLogger("reportlab").setLevel(logging.ERROR) | |
| # ββ Third-party βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import gradio as gr | |
| import fitz | |
| import numpy as np | |
| from pdf2image import convert_from_path | |
| from PIL import Image | |
| # ββ ReportLab βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| from reportlab.pdfgen import canvas | |
| from reportlab.lib.colors import HexColor | |
| from reportlab.pdfbase import pdfmetrics | |
| from reportlab.pdfbase.ttfonts import TTFont | |
| # ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| FONT_CACHE_DIR = "/tmp/pdf_fonts" | |
| os.makedirs(FONT_CACHE_DIR, exist_ok=True) | |
| FONT_REGISTRY = {} | |
| # Stricter placeholder regex β only match [Text with Letters] | |
| PLACEHOLDER_RE = re.compile(r'\[([A-Za-z][A-Za-z0-9\s,\-]+)\]') | |
| STATE = { | |
| "pages_data": None, | |
| "page_images": None, | |
| "placeholders": [], | |
| "pdf_path": None, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def color_int_to_rgb(color_int: int) -> tuple: | |
| r = (color_int >> 16) & 0xFF | |
| g = (color_int >> 8) & 0xFF | |
| b = color_int & 0xFF | |
| return (r/255.0, g/255.0, b/255.0) | |
| def is_repeatable(key: str) -> bool: | |
| k = key.lower() | |
| return any(x in k for x in ["child", "beneficiar", "heir", "trustee", "asset", "property"]) | |
| def infer_field_type(key: str) -> str: | |
| k = key.lower() | |
| if any(x in k for x in ["date", "dob", "day", "year", "month"]): return "date" | |
| if any(x in k for x in ["email"]): return "email" | |
| if any(x in k for x in ["phone", "mobile"]): return "tel" | |
| return "text" | |
| def image_to_base64(img: Image.Image) -> str: | |
| buf = BytesIO() | |
| img.save(buf, format="PNG") | |
| return base64.b64encode(buf.getvalue()).decode() | |
| def pdf_to_images(pdf_path: str, dpi: int = 120): | |
| return convert_from_path(pdf_path, dpi=dpi) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # FONT EXTRACTION & REGISTRATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _fallback_font(name: str) -> str: | |
| n = name.lower() | |
| if "bold" in n and ("italic" in n or "oblique" in n): return "Helvetica-BoldOblique" | |
| if "bold" in n: return "Helvetica-Bold" | |
| if "italic" in n or "oblique" in n: return "Helvetica-Oblique" | |
| if "times" in n: return "Times-Roman" | |
| if "courier" in n or "mono" in n: return "Courier" | |
| return "Helvetica" | |
| def extract_and_register_fonts(pdf_path: str): | |
| global FONT_REGISTRY | |
| doc = fitz.open(pdf_path) | |
| for page in doc: | |
| for font in page.get_fonts(full=True): | |
| xref = font[0] | |
| base_name = font[3] | |
| if xref == 0 or base_name in FONT_REGISTRY: | |
| continue | |
| clean = base_name.split("+")[-1] if "+" in base_name else base_name | |
| try: | |
| font_data = doc.extract_font(xref) | |
| if not font_data or not font_data[3]: | |
| FONT_REGISTRY[base_name] = _fallback_font(clean) | |
| continue | |
| raw = font_data[3] | |
| ext = (font_data[1] or "ttf").lower() | |
| if ext in ("cff", "type1c"): ext = "otf" | |
| if not ext: ext = "ttf" | |
| fhash = hashlib.md5(raw).hexdigest()[:8] | |
| fname = f"{re.sub(r'[^A-Za-z0-9_]','_', clean)}_{fhash}.{ext}" | |
| fpath = os.path.join(FONT_CACHE_DIR, fname) | |
| with open(fpath, "wb") as f: | |
| f.write(raw) | |
| if ext in ("ttf", "otf"): | |
| reg = re.sub(r'[^A-Za-z0-9]', '_', clean) | |
| if reg not in [v for v in FONT_REGISTRY.values()]: | |
| pdfmetrics.registerFont(TTFont(reg, fpath)) | |
| FONT_REGISTRY[base_name] = reg | |
| print(f" Font registered: {reg}") | |
| else: | |
| FONT_REGISTRY[base_name] = _fallback_font(clean) | |
| except Exception as e: | |
| print(f" Font extract failed ({clean}): {e}") | |
| FONT_REGISTRY[base_name] = _fallback_font(clean) | |
| doc.close() | |
| print(f"Total fonts registered: {len(FONT_REGISTRY)}") | |
| def resolve_font(pdf_font_name: str) -> str: | |
| if pdf_font_name in FONT_REGISTRY: | |
| return FONT_REGISTRY[pdf_font_name] | |
| clean = pdf_font_name.split("+")[-1] if "+" in pdf_font_name else pdf_font_name | |
| for k, v in FONT_REGISTRY.items(): | |
| if clean in k: | |
| return v | |
| return _fallback_font(pdf_font_name) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PDF EXTRACTION β Store EXACT positions | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_pdf(pdf_path: str) -> list: | |
| doc = fitz.open(pdf_path) | |
| pages = [] | |
| for page in doc: | |
| w, h = page.rect.width, page.rect.height | |
| data = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE) | |
| elems = [] | |
| for block in data.get("blocks", []): | |
| if block.get("type") != 0: | |
| continue | |
| for line in block.get("lines", []): | |
| for span in line.get("spans", []): | |
| txt = span.get("text", "") | |
| if not txt.strip(): | |
| continue | |
| x0, y0, x1, y1 = span["bbox"] | |
| fl = span.get("flags", 0) | |
| elems.append({ | |
| "content": txt, | |
| "font_size": round(span.get("size", 12), 2), | |
| "font_name": span.get("font", "Helvetica"), | |
| "bold": bool(fl & 16), | |
| "italic": bool(fl & 2), | |
| "color": span.get("color", 0), | |
| "bbox": {"x1": x0, "y1": y0, "x2": x1, "y2": y1}, | |
| }) | |
| pages.append({ | |
| "page_number": page.number + 1, | |
| "dimensions": {"width": w, "height": h}, | |
| "elements": elems, | |
| }) | |
| doc.close() | |
| return pages | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PLACEHOLDER DETECTION β Stricter rules | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def auto_detect_placeholders(pages_data: list) -> list: | |
| found = {} | |
| for page in pages_data: | |
| for elem in page["elements"]: | |
| for m in PLACEHOLDER_RE.findall(elem["content"]): | |
| key = m.strip() | |
| # Ignore pure underscores/symbols | |
| if not any(c.isalpha() for c in key): | |
| continue | |
| if key not in found: | |
| found[key] = { | |
| "key": key, | |
| "question": f"Enter {key}:", | |
| "field_type": infer_field_type(key), | |
| "repeatable": is_repeatable(key), | |
| "default": "", | |
| } | |
| return list(found.values()) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HTML PREVIEW | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_preview_html(pages_data: list, page_images: list) -> str: | |
| html = [] | |
| for page_data, img in zip(pages_data, page_images): | |
| b64 = image_to_base64(img) | |
| w = page_data["dimensions"]["width"] | |
| h = page_data["dimensions"]["height"] | |
| elems = "" | |
| for e in page_data["elements"]: | |
| bbox = e["bbox"] | |
| x1, y1, x2, y2 = bbox["x1"], bbox["y1"], bbox["x2"], bbox["y2"] | |
| left = (x1 / w) * 100 | |
| top = (y1 / h) * 100 | |
| width = ((x2 - x1) / w) * 100 | |
| height = ((y2 - y1) / h) * 100 | |
| fs = e.get("font_size", 12) | |
| txt = (e["content"].replace("&","&") | |
| .replace("<","<") | |
| .replace(">",">")) | |
| has_ph = bool(PLACEHOLDER_RE.search(e["content"])) | |
| bg = "rgba(255,220,0,0.5)" if has_ph else "transparent" | |
| elems += ( | |
| f'<div style="position:absolute;' | |
| f'left:{left:.3f}%;top:{top:.3f}%;' | |
| f'width:{width:.3f}%;height:{height:.3f}%;' | |
| f'font-size:{fs}px;font-family:Arial,sans-serif;' | |
| f'white-space:pre-wrap;overflow:visible;' | |
| f'color:transparent;background:{bg};' | |
| f'cursor:text;user-select:text;" title="{txt}">{txt}</div>' | |
| ) | |
| html.append( | |
| f'<div style="position:relative;width:100%;max-width:850px;' | |
| f'margin:0 auto 20px;box-shadow:0 2px 8px rgba(0,0,0,0.2);">' | |
| f'<img src="data:image/png;base64,{b64}" style="width:100%;display:block;"/>' | |
| f'<div style="position:absolute;top:0;left:0;width:100%;height:100%;">' | |
| f'{elems}</div></div>' | |
| ) | |
| return ( | |
| '<div style="background:#ddd;padding:12px;">' | |
| '<p style="text-align:center;font-family:Arial;font-size:13px;">' | |
| 'π‘ Yellow = detected placeholders (underscores filtered out)</p>' | |
| + "".join(html) + "</div>" | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PDF REGENERATION β ABSOLUTE POSITIONING (exact layout preservation) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def handle_repeatable_block(pages_data: list, placeholders: list, answers: dict) -> list: | |
| """ | |
| Clone pages for each repeatable child/beneficiary. | |
| Returns: expanded pages_data with substituted text. | |
| """ | |
| # Find which placeholders are repeatable | |
| repeat_map = {} | |
| for p in placeholders: | |
| if p.get("repeatable"): | |
| key = p["key"] | |
| bracket = f"[{key}]" | |
| ans = answers.get(key, "") | |
| items = [x.strip() for x in ans.split(";") if x.strip()] if ans else [bracket] | |
| repeat_map[bracket] = items | |
| if not repeat_map: | |
| # No repeatable fields β just return with substitutions | |
| return apply_substitutions(pages_data, placeholders, answers) | |
| # Find pages containing repeatable placeholders | |
| pages_with_repeats = set() | |
| for pid, page in enumerate(pages_data): | |
| for elem in page["elements"]: | |
| for rk in repeat_map.keys(): | |
| if rk in elem["content"]: | |
| pages_with_repeats.add(pid) | |
| break | |
| if not pages_with_repeats: | |
| return apply_substitutions(pages_data, placeholders, answers) | |
| # For now, simple approach: duplicate ENTIRE document for each repeat item | |
| # This ensures continuation text flows properly | |
| result_pages = [] | |
| first_repeat_key = list(repeat_map.keys())[0] | |
| repeat_items = repeat_map[first_repeat_key] | |
| for item_idx, item_value in enumerate(repeat_items): | |
| # Clone all pages | |
| cloned = copy.deepcopy(pages_data) | |
| # Substitute this specific item | |
| single_item_answers = {**answers} | |
| for rk in repeat_map.keys(): | |
| # Extract the key name | |
| key_name = rk[1:-1] # remove [ ] | |
| single_item_answers[key_name] = item_value | |
| cloned = apply_substitutions(cloned, placeholders, single_item_answers) | |
| result_pages.extend(cloned) | |
| return result_pages | |
| def apply_substitutions(pages_data: list, placeholders: list, answers: dict) -> list: | |
| """Apply text replacements to all elements.""" | |
| result = copy.deepcopy(pages_data) | |
| replacement_map = {} | |
| for p in placeholders: | |
| key = p["key"] | |
| bracket = f"[{key}]" | |
| ans = answers.get(key, "") | |
| replacement_map[bracket] = str(ans) if ans else bracket | |
| for page in result: | |
| for elem in page["elements"]: | |
| for placeholder, replacement in replacement_map.items(): | |
| elem["content"] = elem["content"].replace(placeholder, replacement) | |
| return result | |
| def regenerate_pdf_absolute(pages_data: list, placeholders: list, answers: dict, out_path: str) -> str: | |
| """ | |
| Regenerate PDF using Canvas with ABSOLUTE positioning. | |
| This preserves exact layout, fonts, sizes, colors, positions. | |
| """ | |
| # Handle repeatable sections by duplicating pages | |
| expanded_pages = handle_repeatable_block(pages_data, placeholders, answers) | |
| c = canvas.Canvas(out_path) | |
| for page in expanded_pages: | |
| pw = page["dimensions"]["width"] | |
| ph = page["dimensions"]["height"] | |
| c.setPageSize((pw, ph)) | |
| for elem in page["elements"]: | |
| bbox = elem["bbox"] | |
| x = bbox["x1"] | |
| y = ph - bbox["y2"] # PDF coordinate system: origin at bottom-left | |
| # Font | |
| font_name = elem.get("font_name", "Helvetica") | |
| rl_font = resolve_font(font_name) | |
| font_size = elem.get("font_size", 12) | |
| # Color | |
| r, g, b = color_int_to_rgb(elem.get("color", 0)) | |
| c.setFillColorRGB(r, g, b) | |
| # Set font | |
| try: | |
| c.setFont(rl_font, font_size) | |
| except: | |
| c.setFont("Helvetica", font_size) | |
| # Draw text | |
| text = elem["content"] | |
| try: | |
| c.drawString(x, y, text) | |
| except: | |
| # Fallback for special characters | |
| c.drawString(x, y, text.encode('latin-1', 'replace').decode('latin-1')) | |
| c.showPage() | |
| c.save() | |
| return out_path | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GRADIO HANDLERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def admin_upload(pdf_file, progress=gr.Progress()): | |
| if pdf_file is None: | |
| return "<p>No file uploaded.</p>", "[]", gr.update(visible=False) | |
| try: | |
| pdf_path = pdf_file if isinstance(pdf_file, str) else pdf_file.name | |
| STATE["pdf_path"] = pdf_path | |
| progress(0.1, desc="Extracting fonts...") | |
| extract_and_register_fonts(pdf_path) | |
| progress(0.3, desc="Extracting text...") | |
| pages = extract_pdf(pdf_path) | |
| STATE["pages_data"] = pages | |
| progress(0.5, desc="Detecting placeholders...") | |
| phs = auto_detect_placeholders(pages) | |
| STATE["placeholders"] = phs | |
| progress(0.75, desc="Building preview...") | |
| imgs = pdf_to_images(pdf_path) | |
| STATE["page_images"] = imgs | |
| preview = build_preview_html(pages, imgs) | |
| ph_json = json.dumps(phs, indent=2) | |
| progress(1.0, desc="Done!") | |
| return preview, ph_json, gr.update(visible=True) | |
| except Exception as e: | |
| import traceback | |
| msg = traceback.format_exc() | |
| print(msg) | |
| return f"<p style='color:red'>Error: {e}</p>", "[]", gr.update(visible=False) | |
| def admin_save_config(ph_json_str: str): | |
| try: | |
| phs = json.loads(ph_json_str) | |
| STATE["placeholders"] = phs | |
| with open("form_config.json", "w") as f: | |
| json.dump({"placeholders": phs, "pages_data": STATE["pages_data"]}, f, indent=2) | |
| return f"β Saved {len(phs)} placeholder(s). Users can now fill the form." | |
| except Exception as e: | |
| return f"β Error saving config: {e}" | |
| def load_form(): | |
| try: | |
| with open("form_config.json") as f: | |
| cfg = json.load(f) | |
| STATE["pages_data"] = cfg["pages_data"] | |
| STATE["placeholders"] = cfg["placeholders"] | |
| phs = cfg["placeholders"] | |
| defaults = {p["key"]: p.get("default", "") for p in phs} | |
| return "β Form loaded!", phs, json.dumps(defaults, indent=2) | |
| except FileNotFoundError: | |
| return "β οΈ No form configured yet. Admin must upload a PDF first.", [], "{}" | |
| except Exception as e: | |
| return f"β Error: {e}", [], "{}" | |
| def generate_pdf(answers_json: str, progress=gr.Progress()): | |
| try: | |
| answers = json.loads(answers_json) | |
| except Exception as e: | |
| return None, f"<p style='color:red'>Invalid JSON: {e}</p>" | |
| phs = STATE.get("placeholders", []) | |
| pages_data = STATE.get("pages_data") | |
| if not pages_data: | |
| return None, "<p style='color:red'>No PDF loaded. Ask admin to upload first.</p>" | |
| try: | |
| progress(0.3, desc="Rebuilding PDF with exact layout...") | |
| # Re-register fonts if needed | |
| pdf_path = STATE.get("pdf_path") | |
| if pdf_path and os.path.exists(pdf_path): | |
| extract_and_register_fonts(pdf_path) | |
| out_path = "/tmp/filled_output.pdf" | |
| regenerate_pdf_absolute(pages_data, phs, answers, out_path) | |
| progress(0.8, desc="Rendering preview...") | |
| imgs = pdf_to_images(out_path) | |
| pages_preview = "".join( | |
| f'<div style="margin:10px auto;max-width:800px;box-shadow:0 2px 8px rgba(0,0,0,.2);">' | |
| f'<img src="data:image/png;base64,{image_to_base64(img)}" style="width:100%;display:block;"/>' | |
| f'</div>' | |
| for img in imgs | |
| ) | |
| preview = ( | |
| f'<div style="background:#ddd;padding:12px;">' | |
| f'<h3 style="text-align:center;font-family:Arial;">β Generated β {len(imgs)} page(s)</h3>' | |
| f'{pages_preview}</div>' | |
| ) | |
| progress(1.0, desc="Done!") | |
| return out_path, preview | |
| except Exception as e: | |
| import traceback | |
| return None, f"<pre style='color:red'>{traceback.format_exc()}</pre>" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GRADIO UI | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Blocks(title="Smart PDF Form Builder", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown("# π Smart PDF Form Builder") | |
| gr.Markdown("**Admin** uploads & configures β **Users** fill & download exact-layout PDF") | |
| with gr.Tabs(): | |
| # ββ ADMIN ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Tab("π§ Admin"): | |
| gr.Markdown("### Upload PDF Template") | |
| a_file = gr.File(label="Upload PDF", file_types=[".pdf"], type="filepath") | |
| a_btn = gr.Button("Extract & Detect Placeholders", variant="primary") | |
| a_prev = gr.HTML(label="Preview") | |
| with gr.Accordion("Edit Placeholder Config", open=True, visible=False) as a_acc: | |
| gr.Markdown(""" | |
| Edit JSON β fields: | |
| - `key`: text inside `[ ]` in your PDF | |
| - `question`: shown to user | |
| - `field_type`: `text` / `date` / `number` | |
| - `repeatable`: `true` for children/beneficiaries etc. | |
| - `default`: pre-filled value | |
| """) | |
| a_cfg = gr.Code(language="json", label="Config", lines=15) | |
| a_save = gr.Button("πΎ Save for Users", variant="primary") | |
| a_status = gr.Textbox(label="Status", interactive=False) | |
| a_btn.click(admin_upload, [a_file], [a_prev, a_cfg, a_acc]) | |
| a_save.click(admin_save_config, [a_cfg], [a_status]) | |
| # ββ USER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Tab("π Fill Form"): | |
| u_load = gr.Button("π Load Form", variant="secondary") | |
| u_status = gr.Textbox(label="Status", interactive=False) | |
| u_phs = gr.State([]) | |
| gr.Markdown("### Your Answers") | |
| gr.Markdown( | |
| "Edit the JSON below. For **repeatable fields** (children, beneficiaries), " | |
| "separate multiple entries with `;`\n\n" | |
| "Example: `\"Child Full Legal Name, DOB\": \"Emma Johnson, 01/01/2010; Liam Johnson, 03/15/2012\"`" | |
| ) | |
| u_answers = gr.Code(language="json", label="Answers (JSON)", lines=12, value="{}") | |
| def render_questions(phs): | |
| if not phs: | |
| gr.Markdown("*Click 'Load Form' to see questions.*") | |
| return | |
| for p in phs: | |
| hint = " *(separate multiple with `;`)*" if p.get("repeatable") else "" | |
| gr.Markdown(f"**{p['question']}**{hint}") | |
| u_gen = gr.Button("π Generate Filled PDF", variant="primary") | |
| with gr.Row(): | |
| u_out = gr.File(label="π₯ Download PDF") | |
| u_prev = gr.HTML(label="Preview") | |
| def _load(): | |
| status, phs, defaults = load_form() | |
| return status, phs, defaults | |
| u_load.click(_load, [], [u_status, u_phs, u_answers]) | |
| u_gen.click(generate_pdf, [u_answers], [u_out, u_prev]) | |
| # ββ Launch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| gr.close_all() | |
| demo.queue(max_size=10).launch( | |
| server_name="0.0.0.0", | |
| server_port=int(os.environ.get("GRADIO_SERVER_PORT", 7860)), | |
| ssr_mode=False, | |
| show_error=True, | |
| ) | |