"""Tiny document DSL and renderer for the CNX Vinyl business plan. Blocks are plain tuples so the content files stay readable: H1("text") / H2 / H3 headings P("text with **bold** runs") paragraph B([...]) bullet list N([...]) numbered list (manual numbers, restart per list) T(header, rows, widths_mm=None, caption=None, font_pt=9) KV([[k, v], ...], widths_mm) two-column table without header IMG(path, width_mm, caption) picture CALL(title, body) shaded call-out box; body is str or list PB() page break TOC() table-of-contents field """ from __future__ import annotations import re from pathlib import Path from docx import Document from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Mm, Pt, RGBColor NAVY = RGBColor(0x1F, 0x38, 0x64) BLUE = RGBColor(0x2E, 0x55, 0x97) GREY = RGBColor(0x59, 0x59, 0x59) HEADER_FILL = "1F3864" CALLOUT_FILL = "DEEAF6" ALT_FILL = "F2F5FA" BODY_FONT = "Calibri" # ----------------------------------------------------------------- DSL --- def H1(t): return ("h", 1, t) def H2(t): return ("h", 2, t) def H3(t): return ("h", 3, t) def P(t, style=None): return ("p", t, style) def B(items): return ("bullets", items) def N(items): return ("numbers", items) def T(header, rows, widths_mm=None, caption=None, font_pt=9): return ("table", header, rows, widths_mm, caption, font_pt) def KV(rows, widths_mm=(50, 115), font_pt=9.5): return ("kv", rows, widths_mm, font_pt) def IMG(path, width_mm, caption=None): return ("img", path, width_mm, caption) def CALL(title, body): return ("callout", title, body) def PB(): return ("pb",) def TOC(): return ("toc",) def COVER(**kw): return ("cover", kw) # ------------------------------------------------------------ helpers --- def _shade(cell, fill): tcPr = cell._tc.get_or_add_tcPr() shd = OxmlElement("w:shd") shd.set(qn("w:val"), "clear") shd.set(qn("w:color"), "auto") shd.set(qn("w:fill"), fill) tcPr.append(shd) def _field(paragraph, instr, placeholder): run = paragraph.add_run() b = OxmlElement("w:fldChar"); b.set(qn("w:fldCharType"), "begin") i = OxmlElement("w:instrText"); i.set(qn("xml:space"), "preserve"); i.text = instr s = OxmlElement("w:fldChar"); s.set(qn("w:fldCharType"), "separate") t = OxmlElement("w:t"); t.text = placeholder e = OxmlElement("w:fldChar"); e.set(qn("w:fldCharType"), "end") for el in (b, i, s, t, e): run._r.append(el) return run def _set_font(style, name, size=None, bold=None, color=None, italic=None): f = style.font f.name = name rpr = style.element.get_or_add_rPr() rfonts = rpr.find(qn("w:rFonts")) if rfonts is None: rfonts = OxmlElement("w:rFonts"); rpr.append(rfonts) for k in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"): rfonts.set(qn(k), name) if size is not None: f.size = Pt(size) if bold is not None: f.bold = bold if italic is not None: f.italic = italic if color is not None: f.color.rgb = color _INLINE = re.compile(r"(\*\*.+?\*\*)") def _runs(paragraph, text, size=None, color=None): for part in _INLINE.split(text): if not part: continue bold = part.startswith("**") and part.endswith("**") run = paragraph.add_run(part[2:-2] if bold else part) run.bold = bold or None if size is not None: run.font.size = Pt(size) if color is not None: run.font.color.rgb = color return paragraph def _cell_text(cell, text, size, bold=False, color=None, align=None): cell.text = "" para = cell.paragraphs[0] para.paragraph_format.space_after = Pt(1) para.paragraph_format.space_before = Pt(1) if align: para.alignment = align for j, line in enumerate(str(text).split("\n")): if j: para = cell.add_paragraph() para.paragraph_format.space_after = Pt(1) _runs(para, line, size=size, color=color) if bold: for r in para.runs: r.bold = True def _caption(doc, text): p = doc.add_paragraph() p.paragraph_format.space_before = Pt(2) p.paragraph_format.space_after = Pt(8) r = p.add_run(text) r.italic = True r.font.size = Pt(8.5) r.font.color.rgb = GREY return p def _keep_rows_together(table): for row in table.rows: trPr = row._tr.get_or_add_trPr() cs = OxmlElement("w:cantSplit"); trPr.append(cs) # ----------------------------------------------------------- renderer --- class Renderer: def __init__(self, base_dir: Path, header_text: str, footer_text: str): self.base = Path(base_dir) self.doc = Document() self._setup_page() self._setup_styles() self._setup_header_footer(header_text, footer_text) self._update_fields_on_open() # -- setup def _setup_page(self): s = self.doc.sections[0] s.page_width, s.page_height = Mm(210), Mm(297) s.top_margin = s.bottom_margin = Mm(22) s.left_margin = s.right_margin = Mm(20) s.header_distance = s.footer_distance = Mm(10) def _setup_styles(self): st = self.doc.styles _set_font(st["Normal"], BODY_FONT, 10.5) pf = st["Normal"].paragraph_format pf.space_after = Pt(6); pf.line_spacing = 1.08 _set_font(st["Heading 1"], BODY_FONT, 17, True, NAVY) st["Heading 1"].paragraph_format.space_before = Pt(18) st["Heading 1"].paragraph_format.space_after = Pt(8) _set_font(st["Heading 2"], BODY_FONT, 13, True, BLUE) st["Heading 2"].paragraph_format.space_before = Pt(12) st["Heading 2"].paragraph_format.space_after = Pt(4) _set_font(st["Heading 3"], BODY_FONT, 11, True, RGBColor(0x40, 0x40, 0x40)) st["Heading 3"].paragraph_format.space_before = Pt(8) st["Heading 3"].paragraph_format.space_after = Pt(3) for name in ("List Bullet", "List Number"): _set_font(st[name], BODY_FONT, 10.5) st[name].paragraph_format.space_after = Pt(3) def _setup_header_footer(self, header_text, footer_text): s = self.doc.sections[0] hp = s.header.paragraphs[0] hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT r = hp.add_run(header_text); r.font.size = Pt(8.5); r.font.color.rgb = GREY fp = s.footer.paragraphs[0] fp.alignment = WD_ALIGN_PARAGRAPH.CENTER r = fp.add_run(footer_text + " | Page "); r.font.size = Pt(8.5); r.font.color.rgb = GREY r = _field(fp, "PAGE", "1"); r.font.size = Pt(8.5); r.font.color.rgb = GREY r = fp.add_run(" of "); r.font.size = Pt(8.5); r.font.color.rgb = GREY r = _field(fp, "NUMPAGES", "1"); r.font.size = Pt(8.5); r.font.color.rgb = GREY def _update_fields_on_open(self): el = OxmlElement("w:updateFields"); el.set(qn("w:val"), "true") self.doc.settings.element.append(el) # -- blocks def render(self, blocks): for blk in blocks: kind = blk[0] getattr(self, "_b_" + kind)(*blk[1:]) return self.doc def _b_cover(self, kw): d = self.doc for _ in range(3): d.add_paragraph() p = d.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.LEFT r = p.add_run(kw["kicker"]); r.font.size = Pt(12); r.font.color.rgb = BLUE; r.bold = True p = d.add_paragraph() r = p.add_run(kw["title"]); r.font.size = Pt(30); r.font.color.rgb = NAVY; r.bold = True p.paragraph_format.space_after = Pt(6) p = d.add_paragraph() r = p.add_run(kw["subtitle"]); r.font.size = Pt(13); r.font.color.rgb = GREY p.paragraph_format.space_after = Pt(14) if kw.get("image"): d.add_picture(str(self.base / kw["image"]), width=Mm(170)) p = d.add_paragraph(); p.paragraph_format.space_before = Pt(10) for line in kw["lines"]: _runs(p, line, size=10.5); p.add_run().add_break() p = d.add_paragraph() r = p.add_run(kw["note"]); r.font.size = Pt(8.5); r.italic = True; r.font.color.rgb = GREY d.add_paragraph().add_run().add_break(WD_BREAK.PAGE) def _b_toc(self): d = self.doc p = d.add_paragraph(); r = p.add_run("Contents"); r.font.size = Pt(17); r.bold = True; r.font.color.rgb = NAVY p = d.add_paragraph() _field(p, 'TOC \\o "1-2" \\h \\z \\u', "Table of contents: open in Word and press F9 (or accept the update prompt) to populate.") d.add_paragraph().add_run().add_break(WD_BREAK.PAGE) def _b_h(self, level, text): self.doc.add_heading(text, level=level) def _b_p(self, text, style): p = self.doc.add_paragraph(style=style) if style else self.doc.add_paragraph() _runs(p, text) def _b_bullets(self, items): for it in items: _runs(self.doc.add_paragraph(style="List Bullet"), it) def _b_numbers(self, items): for i, it in enumerate(items, 1): p = self.doc.add_paragraph() p.paragraph_format.left_indent = Mm(8) p.paragraph_format.first_line_indent = Mm(-6) p.paragraph_format.space_after = Pt(3) _runs(p, f"{i}. {it}") def _b_table(self, header, rows, widths, caption, font_pt): d = self.doc n = len(header) t = d.add_table(rows=1, cols=n) t.style = d.styles["Table Grid"] t.alignment = WD_TABLE_ALIGNMENT.CENTER for j, h in enumerate(header): c = t.rows[0].cells[j] _shade(c, HEADER_FILL) _cell_text(c, h, font_pt, bold=True, color=RGBColor(0xFF, 0xFF, 0xFF)) for i, row in enumerate(rows): cells = t.add_row().cells for j in range(n): val = row[j] if j < len(row) else "" _cell_text(cells[j], val, font_pt) if i % 2 == 1: _shade(cells[j], ALT_FILL) if widths: t.autofit = False for j, w in enumerate(widths): t.columns[j].width = Mm(w) # grid columns: LibreOffice reads these for row in t.rows: for j, w in enumerate(widths): row.cells[j].width = Mm(w) # repeat header row trPr = t.rows[0]._tr.get_or_add_trPr() th = OxmlElement("w:tblHeader"); th.set(qn("w:val"), "true"); trPr.append(th) _keep_rows_together(t) if caption: _caption(d, caption) else: d.add_paragraph().paragraph_format.space_after = Pt(2) def _b_kv(self, rows, widths, font_pt): d = self.doc t = d.add_table(rows=0, cols=2) t.style = d.styles["Table Grid"] t.alignment = WD_TABLE_ALIGNMENT.CENTER for k, v in rows: cells = t.add_row().cells _shade(cells[0], ALT_FILL) _cell_text(cells[0], k, font_pt, bold=True) _cell_text(cells[1], v, font_pt) t.autofit = False for j, w in enumerate(widths): t.columns[j].width = Mm(w) for row in t.rows: for j, w in enumerate(widths): row.cells[j].width = Mm(w) _keep_rows_together(t) d.add_paragraph().paragraph_format.space_after = Pt(2) def _b_img(self, path, width_mm, caption): self.doc.add_picture(str(self.base / path), width=Mm(width_mm)) self.doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER if caption: _caption(self.doc, caption) def _b_callout(self, title, body): d = self.doc t = d.add_table(rows=1, cols=1) t.style = d.styles["Table Grid"] t.alignment = WD_TABLE_ALIGNMENT.CENTER c = t.rows[0].cells[0] _shade(c, CALLOUT_FILL) c.width = Mm(170) c.text = "" p = c.paragraphs[0] p.paragraph_format.space_before = Pt(3); p.paragraph_format.space_after = Pt(3) r = p.add_run(title); r.bold = True; r.font.color.rgb = NAVY; r.font.size = Pt(10.5) items = body if isinstance(body, list) else [body] for it in items: q = c.add_paragraph() q.paragraph_format.space_after = Pt(3) if isinstance(body, list): q.paragraph_format.left_indent = Mm(4) _runs(q, "\u2022 " + it, size=10) else: _runs(q, it, size=10) _keep_rows_together(t) d.add_paragraph().paragraph_format.space_after = Pt(2) def _b_pb(self): self.doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE) def save(self, path): self.doc.save(str(path))