Files
grabowski d42e99389b Business plan draft 0.2: re-based on the launch plant
The investor business plan (docx + pdf) rebuilt for the launch plant
decided in October 2026: one Duplex system and one Classic, Pheenix
galvanics with zero discharge, the Sibert line, a Sillitoe cutting room,
modular steam and cooling (rooms for four), cassette and CD-R; no bridge
stampers, no outsourced cutting; second Duplex as a growth option.

- Part A: executive summary (what changed since 0.1, the numbers, the
  180M / 216M answer, first release marked open), reading guide, two
  price points (ResurRec and Record Industry), anatomy rewritten for
  Sillitoe, Pheenix, Sibert, Duplex + Classic, lead times without bridge
  stampers, new 4.8 artist platform, cassette 4.9, CD 4.10.
- Part B: traceability with ERPNext as system of record, metrology launch
  tier as the investor's choice, QC, efficiency (PEA tariff, utility
  modules, fuel open, Chinese boilers and DIW rules), site (Mueang Chi,
  Payap zoning result), building blocks, safety, BOI and licences with
  galvanics in the first factory licence.
- Part C: launch-plant investment table, the investor's question and
  levers, tranches, currency, operating picture of the launch plant,
  timeline with the metal path on the critical path, commissioning
  sequence and why the whole chain at launch, team, 22 risks, 17 phase 0
  actions, the ask.
- plan_lib: grid column widths so LibreOffice keeps table widths.
- export_pdf.py: LibreOffice UNO export that fills the table of
  contents (47 pages).
- README: total investment, revenue model and business plan bullets.
2026-10-16 16:01:25 +07:00

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