178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
#!/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())
|