dental-soap / pdf_export.py
DrZayed's picture
deploy: harden input clamps + temp-dir cleanup (audit MEDIUM)
426fb0a
Raw
History Blame Contribute Delete
9.29 kB
"""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"<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(" &middot; ".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)