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
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
226 lines
7.5 KiB
Python
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())
|