#!/usr/bin/env python3 # MIT License. Part of the Hermes docx skill. """Create a .docx document from a JSON spec. Usage: docx_create.py spec.json output.docx Run with --help for the spec format summary. Spec (JSON object): { "page": {"width_mm": 210, "height_mm": 297, "margins_mm": {"top": 25, "bottom": 25, "left": 20, "right": 20}}, "header": "text shown in page header", "footer": "text shown in page footer", "styles": [{"name": "MyStyle", "base": "Normal", "font": "Arial", "size_pt": 12, "bold": true, "color": "1F4E79"}], "blocks": [ {"type": "heading", "text": "Title", "level": 1}, {"type": "paragraph", "style": "MyStyle", "runs": [ {"text": "plain "}, {"text": "bold", "bold": true}, {"text": " italic", "italic": true}, {"text": " under", "underline": true}]}, {"type": "paragraph", "text": "shortcut: single plain run"}, {"type": "bullet_list", "items": ["a", "b"]}, {"type": "numbered_list", "items": ["one", "two"]}, {"type": "table", "header": ["Col1", "Col2"], "rows": [["1", "2"]], "style": "Light Grid Accent 1", "header_bold": true}, {"type": "image", "path": "pic.png", "width_mm": 60}, {"type": "page_break"}, {"type": "toc"} ] } Extras: `"footer_page_numbers": true` at the top level adds a "Page X of Y" footer built from PAGE/NUMPAGES fields, and a `toc` block inserts a Table of Contents field. Field results are computed by Word/LibreOffice when the file is opened, not by python-docx. """ from __future__ import annotations import argparse import json import sys from docx import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK from docx.shared import Mm, Pt, RGBColor def apply_page(doc, page: dict) -> None: section = doc.sections[0] if "width_mm" in page: section.page_width = Mm(page["width_mm"]) if "height_mm" in page: section.page_height = Mm(page["height_mm"]) m = page.get("margins_mm", {}) for side in ("top", "bottom", "left", "right"): if side in m: setattr(section, f"{side}_margin", Mm(m[side])) def add_styles(doc, styles: list) -> None: for s in styles: style = doc.styles.add_style(s["name"], WD_STYLE_TYPE.PARAGRAPH) if s.get("base"): style.base_style = doc.styles[s["base"]] font = style.font if s.get("font"): font.name = s["font"] if s.get("size_pt"): font.size = Pt(s["size_pt"]) if s.get("bold") is not None: font.bold = s["bold"] if s.get("italic") is not None: font.italic = s["italic"] if s.get("color"): font.color.rgb = RGBColor.from_string(s["color"]) def add_runs(para, block: dict) -> None: runs = block.get("runs") if runs is None: runs = [{"text": block.get("text", "")}] for r in runs: run = para.add_run(r.get("text", "")) if r.get("bold"): run.bold = True if r.get("italic"): run.italic = True if r.get("underline"): run.underline = True def add_block(doc, block: dict) -> None: btype = block["type"] if btype == "heading": doc.add_heading(block.get("text", ""), level=block.get("level", 1)) elif btype == "paragraph": para = doc.add_paragraph(style=block.get("style")) add_runs(para, block) elif btype == "bullet_list": for item in block.get("items", []): doc.add_paragraph(item, style="List Bullet") elif btype == "numbered_list": for item in block.get("items", []): doc.add_paragraph(item, style="List Number") elif btype == "table": header = block.get("header", []) rows = block.get("rows", []) ncols = len(header) if header else (len(rows[0]) if rows else 1) table = doc.add_table(rows=0, cols=ncols) table.style = block.get("style", "Table Grid") if header: cells = table.add_row().cells for i, text in enumerate(header): cells[i].text = str(text) if block.get("header_bold", True): for para in cells[i].paragraphs: for run in para.runs: run.bold = True for row in rows: cells = table.add_row().cells for i, text in enumerate(row): cells[i].text = str(text) elif btype == "image": width = Mm(block["width_mm"]) if block.get("width_mm") else None doc.add_picture(block["path"], width=width) elif btype == "page_break": doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE) elif btype == "toc": from docx_edit import _add_field para = doc.add_paragraph() _add_field(para, r' TOC \o "1-3" \h \z \u ', "Table of contents - open in Word/LibreOffice and " "update fields to populate.") else: raise ValueError(f"unknown block type: {btype}") def main() -> int: ap = argparse.ArgumentParser( description="Create a .docx from a JSON spec.", epilog="See the module docstring (top of this file) for the spec format.") ap.add_argument("spec", help="path to JSON spec file") ap.add_argument("output", help="path of .docx to write") args = ap.parse_args() with open(args.spec, encoding="utf-8") as f: spec = json.load(f) doc = Document() if spec.get("page"): apply_page(doc, spec["page"]) if spec.get("styles"): add_styles(doc, spec["styles"]) if spec.get("header"): doc.sections[0].header.paragraphs[0].text = spec["header"] if spec.get("footer"): doc.sections[0].footer.paragraphs[0].text = spec["footer"] for block in spec.get("blocks", []): add_block(doc, block) if spec.get("footer_page_numbers"): from docx_edit import _add_field para = doc.sections[0].footer.paragraphs[0] para.add_run("Page ") _add_field(para, " PAGE ", "1") para.add_run(" of ") _add_field(para, " NUMPAGES ", "1") doc.save(args.output) print(json.dumps({"ok": True, "output": args.output, "blocks": len(spec.get("blocks", []))})) return 0 if __name__ == "__main__": sys.exit(main())