#!/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 (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())