Files
cnx_vinyl/docs/business-plan/build/plan_lib.py
T
grabowski 3934dc1dc4 Add investor business plan (draft 0.1) with its Python builder
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.
2026-09-10 10:20:05 +02:00

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))