pediatric-ai-scribe-v3/scripts/render_docx.py
Daniel 154b896d5b
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
feat: Word is built by python-docx from the same typed source as the deck
Pandoc reads markdown, so every Word export had to flatten the resource to
markdown first — and a deck flattened to markdown stops being one. A comparison
became two headings and two lists, a callout became bold text, and a figure
became nothing at all, because markdown has nowhere to put it.

src/utils/docSpec.js reduces either source to the same blocks: a stored deck
where there is one, the markdown where there is not. scripts/render_docx.py
draws them. A comparison comes out as a labelled two-column table, a callout as
a shaded box, a table as a real table, a figure embedded at its own aspect ratio
with its caption, and speaker notes as muted indented text.

The deck wins over the markdown beside it, because that markdown is a
serialisation of the deck and reading it instead would be reading a lossy copy of
what is right there.

Word now carries the figures too. The export route skipped fetching them for
docx, which was correct when pandoc could not place them and wrong the moment
this could.

Pandoc stays installed and stays the fallback: a plainer document beats a failed
download. Both renderers now share one spawn helper.

Verified end to end: a deck with two figures exported as a six-page Word document
with both images embedded (537KB, two files in word/media), rendered to PDF and
looked at — the comparison is a labelled table, the figure sits at its true
aspect ratio, and the notes read as notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 21:58:53 +02:00

226 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Render a document from a JSON spec on stdin to a .docx file.
Replaces pandoc for this path. Pandoc reads markdown, so everything had to be
flattened to markdown first — and a deck flattened to markdown loses what made
it a deck: a comparison became two headings and two lists, a callout became bold
text, a figure became nothing at all. Coming from the typed spec, a comparison
is a two-column table, a callout is a shaded box, and a figure keeps its caption.
Input (stdin, JSON):
{"title": str, "subtitle": str, "date": str, "blocks": [ ... ]}
Blocks:
{"type":"heading","level":1..4,"text":str}
{"type":"para","text":str,"muted":bool}
{"type":"bullets","items":[{"text":str,"level":0..4}]}
{"type":"table","header":[str],"rows":[[str]]}
{"type":"callout","text":str}
{"type":"image","path":str,"caption":str}
Output: argv[1]. Errors to stderr, non-zero exit, so the caller can fall back.
"""
import json
import os
import re
import sys
from docx import Document
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Emu, Pt, RGBColor
try:
from PIL import Image
except Exception: # pragma: no cover
Image = None
INK = RGBColor(0x1F, 0x29, 0x37)
MUTED = RGBColor(0x4B, 0x55, 0x63)
ACCENT = RGBColor(0x25, 0x63, 0xEB)
CALLOUT_INK = RGBColor(0x78, 0x35, 0x0F)
BODY_FONT = "Calibri"
CONTENT_WIDTH_EMU = Emu(5486400) # 6" — A4/Letter with 1" margins
def shade(element, hex_fill):
"""Cell or paragraph shading. python-docx exposes no API for either."""
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), hex_fill)
element.append(shd)
def style_body(document):
normal = document.styles["Normal"]
normal.font.name = BODY_FONT
normal.font.size = Pt(11)
normal.font.color.rgb = INK
normal.paragraph_format.space_after = Pt(8)
normal.paragraph_format.line_spacing = 1.15
def add_runs(paragraph, text, bold=False, color=None, size=None):
"""Inline **bold** and *italic* survive; everything else is literal."""
for chunk, is_bold, is_italic in split_inline(text):
run = paragraph.add_run(chunk)
run.bold = bold or is_bold
run.italic = is_italic
run.font.name = BODY_FONT
if color is not None:
run.font.color.rgb = color
if size is not None:
run.font.size = Pt(size)
def split_inline(text):
out = []
pattern = re.compile(r"(\*\*|__)(.+?)\1|(\*|_)(.+?)\3")
index = 0
for match in pattern.finditer(text or ""):
if match.start() > index:
out.append((text[index:match.start()], False, False))
if match.group(2) is not None:
out.append((match.group(2), True, False))
else:
out.append((match.group(4), False, True))
index = match.end()
if index < len(text or ""):
out.append((text[index:], False, False))
return out or [(text or "", False, False)]
def heading(document, spec):
level = max(1, min(int(spec.get("level") or 2), 4))
para = document.add_paragraph()
para.paragraph_format.space_before = Pt(16 if level <= 2 else 12)
para.paragraph_format.space_after = Pt(6)
para.paragraph_format.keep_with_next = True
sizes = {1: 18, 2: 14, 3: 12, 4: 11}
add_runs(para, spec.get("text") or "", bold=True,
color=ACCENT if level == 1 else INK, size=sizes[level])
def para(document, spec):
p = document.add_paragraph()
add_runs(p, spec.get("text") or "", color=MUTED if spec.get("muted") else None,
size=10 if spec.get("muted") else None)
if spec.get("muted"):
p.paragraph_format.left_indent = Emu(228600)
def bullets(document, spec):
for item in spec.get("items") or []:
text = (item.get("text") or "").strip()
if not text:
continue
level = max(0, min(int(item.get("level") or 0), 4))
p = document.add_paragraph(style="List Bullet" if level == 0 else "List Bullet 2")
p.paragraph_format.left_indent = Emu(228600 * (level + 1))
p.paragraph_format.space_after = Pt(4)
add_runs(p, text)
def table(document, spec):
header = spec.get("header") or []
rows = spec.get("rows") or []
if not rows:
return
cols = max(len(header), max((len(r) for r in rows), default=1))
t = document.add_table(rows=len(rows) + (1 if header else 0), cols=cols)
t.style = "Table Grid"
t.alignment = WD_TABLE_ALIGNMENT.CENTER
offset = 0
if header:
for c in range(cols):
cell = t.cell(0, c)
cell.text = ""
add_runs(cell.paragraphs[0], header[c] if c < len(header) else "", bold=True, size=10)
shade(cell._tc.get_or_add_tcPr(), "E7EEFC")
offset = 1
for r, row in enumerate(rows):
for c in range(cols):
cell = t.cell(r + offset, c)
cell.text = ""
add_runs(cell.paragraphs[0], row[c] if c < len(row) else "", size=10)
document.add_paragraph().paragraph_format.space_after = Pt(4)
def callout(document, spec):
"""One thing worth stopping on, in a shaded box with a rule down its side."""
t = document.add_table(rows=1, cols=1)
t.alignment = WD_TABLE_ALIGNMENT.CENTER
cell = t.cell(0, 0)
cell.text = ""
shade(cell._tc.get_or_add_tcPr(), "FEF3C7")
add_runs(cell.paragraphs[0], spec.get("text") or "", bold=True, color=CALLOUT_INK)
document.add_paragraph().paragraph_format.space_after = Pt(4)
def image(document, spec):
path = spec.get("path")
if not path or not os.path.exists(path):
return
width = CONTENT_WIDTH_EMU
if Image is not None:
try:
with Image.open(path) as img:
# A tall figure at full width runs off the page; cap the height
# and let the width follow rather than stretching either.
if img.width and img.height and (img.height / img.width) > 1.1:
width = Emu(int(CONTENT_WIDTH_EMU * 0.62))
except Exception:
pass
document.add_picture(path, width=width)
document.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER
caption = (spec.get("caption") or "").strip()
if caption:
p = document.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
add_runs(p, caption, color=MUTED, size=9)
p.runs[0].italic = True
BUILDERS = {
"heading": heading, "para": para, "bullets": bullets,
"table": table, "callout": callout, "image": image,
}
def main():
if len(sys.argv) < 2:
print("usage: render_docx.py <output.docx> (spec on stdin)", file=sys.stderr)
return 2
spec = json.load(sys.stdin)
blocks = spec.get("blocks") or []
if not blocks:
print("spec contains no blocks", file=sys.stderr)
return 3
document = Document()
style_body(document)
title = document.add_paragraph()
title.paragraph_format.space_after = Pt(2)
add_runs(title, spec.get("title") or "Resource", bold=True, size=24)
for line in [spec.get("subtitle"), spec.get("date")]:
if not line:
continue
sub = document.add_paragraph()
sub.paragraph_format.space_after = Pt(0)
add_runs(sub, line, color=MUTED, size=11)
document.add_paragraph()
for block in blocks:
BUILDERS.get(block.get("type") or "para", para)(document, block)
document.save(sys.argv[1])
return 0
if __name__ == "__main__":
sys.exit(main())