| """PDF export for the Dentist Visit Handoff via reportlab (pure Python, Spaces-safe). |
| |
| English-only by design: the Arabic card uses the browser print path, since correct |
| Arabic shaping in reportlab needs extra font/reshaping dependencies not worth the risk. |
| """ |
| from __future__ import annotations |
|
|
| import tempfile |
| from datetime import date |
| from html import escape as html_escape |
| from pathlib import Path |
|
|
| from render import DISCLAIMER, tier_label |
| from schema import HandoffOutput |
|
|
| |
| |
| BRAND = "#0D0D0D" |
|
|
| _ARABIC_RE = __import__("re").compile(r"[-ۿݐ-ݿࢠ-ࣿﭐ-﷿ﹰ-]") |
|
|
|
|
| def _has_arabic(output: HandoffOutput) -> bool: |
| """Return True if any text field in the payload contains Arabic codepoints.""" |
| fields: list[str] = [ |
| output.chief_concern, |
| output.concise_summary, |
| output.artifact_label, |
| ] |
| for lst in ( |
| output.timeline, |
| output.current_symptoms, |
| output.dental_history, |
| output.medical_safety_notes, |
| output.patient_goals, |
| output.dentist_questions, |
| output.after_visit_tracker, |
| output.bring_checklist, |
| output.limitations, |
| ): |
| fields.extend(lst) |
| return any(_ARABIC_RE.search(f) for f in fields if f) |
|
|
|
|
| TEAL = "#6B665C" |
| DANGER = "#B45309" |
| INK = "#0D0D0D" |
| MUTED = "#46423B" |
| LINE = "#E2DFD6" |
|
|
|
|
| def _sweep_stale_pdf_dirs(max_age_seconds: float = 3600.0) -> None: |
| """Remove leftover ``dentalsoap_*`` temp dirs from earlier builds. |
| |
| Each handoff PDF is written into a fresh ``mkdtemp`` dir that Gradio serves as |
| a download; nothing cleans them, so on a long-running Space /tmp grows without |
| bound. We sweep dirs older than an hour (their downloads are long done) at the |
| start of every build, leaving the just-created one untouched. Best-effort: |
| filesystem races never propagate into the PDF path. |
| """ |
| import shutil |
| import time |
|
|
| root = Path(tempfile.gettempdir()) |
| now = time.time() |
| try: |
| candidates = list(root.glob("dentalsoap_*")) |
| except OSError: |
| return |
| for entry in candidates: |
| try: |
| if entry.is_dir() and (now - entry.stat().st_mtime) > max_age_seconds: |
| shutil.rmtree(entry, ignore_errors=True) |
| except OSError: |
| continue |
|
|
|
|
| def build_pdf(output: HandoffOutput) -> str | None: |
| """Render the handoff to a temp PDF. Returns the path, or None if reportlab is missing. |
| |
| If the payload contains Arabic text, the PDF is rendered English-only with a note |
| directing the user to the on-screen bilingual view or the browser Print function. |
| ReportLab's built-in fonts have no Arabic glyph coverage; arabic-reshaper/python-bidi |
| are not available in this Space, so tofu rendering is avoided by this guard. |
| """ |
| try: |
| from reportlab.lib import colors |
| from reportlab.lib.pagesizes import A4 |
| from reportlab.lib.styles import ParagraphStyle |
| from reportlab.lib.units import mm |
| from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer |
| except ImportError: |
| return None |
|
|
| arabic_payload = _has_arabic(output) |
|
|
| _sweep_stale_pdf_dirs() |
| path = Path(tempfile.mkdtemp(prefix="dentalsoap_")) / "Dentist_Visit_Handoff.pdf" |
|
|
| kicker = ParagraphStyle("kicker", fontName="Helvetica-Bold", fontSize=8, textColor=colors.HexColor(TEAL), leading=11) |
| title = ParagraphStyle("title", fontName="Helvetica-Bold", fontSize=17, textColor=colors.HexColor(BRAND), leading=21) |
| meta = ParagraphStyle("meta", fontName="Helvetica", fontSize=8.5, textColor=colors.HexColor(MUTED), leading=12) |
| heading = ParagraphStyle( |
| "heading", fontName="Helvetica-Bold", fontSize=9.5, textColor=colors.HexColor(BRAND), leading=13, spaceBefore=9 |
| ) |
| body = ParagraphStyle("body", fontName="Helvetica", fontSize=10, textColor=colors.HexColor(INK), leading=14.5) |
| bullet = ParagraphStyle("bullet", parent=body, leftIndent=12, bulletIndent=2) |
| flag = ParagraphStyle("flag", parent=body, textColor=colors.HexColor(DANGER), leftIndent=12, bulletIndent=2) |
|
|
| def rule() -> HRFlowable: |
| return HRFlowable(width="100%", thickness=0.6, color=colors.HexColor(LINE), spaceAfter=3) |
|
|
| def footer(canvas, doc) -> None: |
| canvas.saveState() |
| canvas.setFont("Helvetica", 7) |
| canvas.setFillColor(colors.HexColor(MUTED)) |
| canvas.drawString(18 * mm, 10 * mm, DISCLAIMER) |
| canvas.drawRightString(192 * mm, 10 * mm, f"Page {doc.page}") |
| canvas.restoreState() |
|
|
| def _safe_text(text: str) -> str: |
| """Return ReportLab-safe plain text. |
| |
| Common Unicode punctuation is mapped to latin-1 equivalents FIRST — |
| otherwise em dashes and curly quotes degrade to "?" on the printed |
| artifact. Paragraph treats angle brackets and ampersands as markup, so |
| patient input is escaped after the latin-1 degradation step and cannot |
| inject tags or break PDF generation. |
| """ |
|
|
| text = ( |
| text.replace("—", "-") |
| .replace("–", "-") |
| .replace("‘", "'") |
| .replace("’", "'") |
| .replace("“", '"') |
| .replace("”", '"') |
| .replace("…", "...") |
| .replace(" ", " ") |
| ) |
| latin_text = text.encode("latin-1", errors="replace").decode("latin-1") |
| return html_escape(latin_text) |
|
|
| |
| |
| pdf_title = "Dentist Visit Handoff" if arabic_payload else output.artifact_label |
|
|
| patient_meta = f"Patient: {_safe_text(output.patient_name)}" if output.patient_name else "" |
| if output.patient_age is not None: |
| age_str = f"Age: {output.patient_age}" |
| patient_meta = f"{patient_meta} ({age_str})" if patient_meta else age_str |
| patient_meta_sep = " · " if patient_meta else "" |
|
|
| story = [ |
| Paragraph("DENTAL SOAP · VISIT PREP", kicker), |
| Paragraph(_safe_text(pdf_title), title), |
| Paragraph(f"{patient_meta}{patient_meta_sep}Date: {date.today().isoformat()} · Patient-authored · Not a diagnosis", meta), |
| Spacer(1, 6), |
| ] |
|
|
| if arabic_payload: |
| arabic_note = ParagraphStyle( |
| "arabic_note", |
| fontName="Helvetica-Oblique", |
| fontSize=9, |
| textColor=colors.HexColor(MUTED), |
| leading=13, |
| spaceBefore=4, |
| spaceAfter=8, |
| ) |
| story.append( |
| Paragraph( |
| "Arabic version available on screen — use Print for the bilingual copy.", |
| arabic_note, |
| ) |
| ) |
| story.append(rule()) |
|
|
| def add_section(label: str, items: list[str], numbered: bool = False, boxes: bool = False) -> None: |
| story.append(Paragraph(label.upper(), heading)) |
| story.append(rule()) |
| for i, item in enumerate(items, start=1): |
| marker = f"{i}." if numbered else ("[ ]" if boxes else "•") |
| story.append(Paragraph(_safe_text(item), bullet, bulletText=marker)) |
|
|
| if output.red_flags: |
| story.append(Paragraph("SAFETY FLAGS (RULE-BASED)", heading)) |
| story.append(rule()) |
| for finding in output.red_flags: |
| story.append( |
| Paragraph( |
| f"<b>[{tier_label(finding.tier)}]</b> " |
| f"{_safe_text(finding.title)}: {_safe_text(finding.patient_message)}", |
| flag, |
| bulletText="!", |
| ) |
| ) |
|
|
| add_section("Chief concern", [output.chief_concern]) |
| add_section("Concise summary", [output.concise_summary]) |
| add_section("Timeline / story evidence", output.timeline) |
| add_section("Current symptoms", output.current_symptoms) |
| add_section("Relevant dental history", output.dental_history) |
| add_section("Medical safety notes", output.medical_safety_notes) |
| add_section("Patient goals", output.patient_goals) |
| if output.bring_checklist: |
| add_section("Bring to the visit", output.bring_checklist, boxes=True) |
| add_section("Questions for the dentist", output.dentist_questions, numbered=True) |
| add_section("After-visit tracker", output.after_visit_tracker, boxes=True) |
|
|
| story.append(Spacer(1, 10)) |
| story.append(rule()) |
| story.append(Paragraph(" · ".join(_safe_text(lim) for lim in output.limitations), meta)) |
|
|
| doc = SimpleDocTemplate( |
| str(path), |
| pagesize=A4, |
| topMargin=16 * mm, |
| bottomMargin=18 * mm, |
| leftMargin=18 * mm, |
| rightMargin=18 * mm, |
| title="Dentist Visit Handoff", |
| author="Dental SOAP", |
| ) |
| try: |
| doc.build(story, onFirstPage=footer, onLaterPages=footer) |
| except Exception: |
| return None |
| return str(path) |
|
|