Leased land instead of purchase, in-house cassette duplication, CD-R duplication with CD replication shown as a separate investor decision, Ploy Printing as packaging partner and intended co-investor, and a dedicated section on traceability, metrology, QC and efficiency.
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""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:
|
|
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)
|
|
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))
|