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.
327 lines
12 KiB
Python
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))
|