"""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 # Matches the app's --ink-900 token in style.css so the printed artifact and # the screen are visibly the same product (ivory/ink/amber design system). 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" # kicker accent — --ink-500 in style.css (name kept for API stability) DANGER = "#B45309" # safety flags — --amber-700; amber owns safety, no red INK = "#0D0D0D" # --ink-900 in style.css MUTED = "#46423B" # --ink-600 in style.css LINE = "#E2DFD6" # --ivory-300 in style.css 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("—", "-") # em dash .replace("–", "-") # en dash .replace("‘", "'") .replace("’", "'") .replace("“", '"') .replace("”", '"') .replace("…", "...") .replace(" ", " ") ) latin_text = text.encode("latin-1", errors="replace").decode("latin-1") return html_escape(latin_text) # Use a static English title when the payload contains Arabic; artifact_label may # contain Arabic text that reportlab's built-in fonts cannot render (tofu boxes). 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"[{tier_label(finding.tier)}] " 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)