build_sop.py
Language Python16.0 KB441 lines
#!/usr/bin/env python3"""Meridian SOP BuilderTakes a Python dict describing an SOP and produces a styled .docx file matchingthe Meridian Display Co. company SOP template.Usage:from build_sop import build_sopbuild_sop({"title": "Mockup File Naming Convention","owner": "Matt Guerra","initials": "MG","category": "Operations","effective_date": "04/2026","folder_context": "07 - Mockup Artwork Files","sections": [{"type": "purpose", "text": "..."},{"type": "scope", "text": "..."},{"type": "h1", "text": "Folder Structure"},{"type": "body", "text": "..."},{"type": "code", "text": "07 - Mockup Artwork Files\n├── ..."},{"type": "accent_subhead", "text": "Versioning"},{"type": "bullet", "text": "Start at v1 for every new job."},{"type": "components_table", "rows": [["Component", "Value", "Description"], ["v[#]", "v1, v2...", "Version number"]]},{"type": "examples_table", "rows": [["Folder", "Sub DIR", "File Name(s)"], ["NORTHWIND ACADEMY", "H4A", "Mockup_v1_04/2026_RG"]]},{"type": "note", "text": "Note: This applies to ... only."},{"type": "quick_ref", "text": "Mockup Format:\nMockup_v[#]_..."},],"output_path": "/path/to/output.docx",})Run as a CLI:python3 build_sop.py --validate /path/to/file.docx"""import argparseimport osimport reimport shutilimport sysfrom pathlib import Pathfrom docx import Documentfrom docx.shared import Pt, Inches, RGBColor, Emufrom docx.enum.text import WD_ALIGN_PARAGRAPHfrom docx.oxml.ns import qnfrom docx.oxml import OxmlElementMERIDIAN_NAVY = RGBColor(0x1F, 0x3A, 0x5F)WARN_RED = RGBColor(0xFF, 0x00, 0x00)BODY_BLACK = RGBColor(0x00, 0x00, 0x00)GRAY = RGBColor(0x43, 0x43, 0x43)BODY_FONT = "Arial"CODE_FONT = "Courier New"TEMPLATE_PATH = Path(__file__).parent.parent / "assets" / "template.docx"def _set_cell_background(cell, hex_color):tc_pr = cell._tc.get_or_add_tcPr()shd = OxmlElement('w:shd')shd.set(qn('w:fill'), hex_color)shd.set(qn('w:val'), 'clear')tc_pr.append(shd)def _apply_table_borders(table, color="B0B0B0", size="4"):"""Apply thin gray borders to every cell of a table via raw XML."""tbl_pr = table._element.find(qn('w:tblPr'))if tbl_pr is None:tbl_pr = OxmlElement('w:tblPr')table._element.insert(0, tbl_pr)tbl_borders = OxmlElement('w:tblBorders')for edge in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):b = OxmlElement(f'w:{edge}')b.set(qn('w:val'), 'single')b.set(qn('w:sz'), size)b.set(qn('w:color'), color)tbl_borders.append(b)# Remove any existing borders blockexisting = tbl_pr.find(qn('w:tblBorders'))if existing is not None:tbl_pr.remove(existing)tbl_pr.append(tbl_borders)def _style_run(run, font=BODY_FONT, size=11, bold=False, italic=False, color=BODY_BLACK):run.font.name = fontrun.font.size = Pt(size)run.bold = boldrun.italic = italicif color is not None:run.font.color.rgb = colordef _add_styled_para(doc, text, *, style=None, font=BODY_FONT, size=11, bold=False,italic=False, color=BODY_BLACK, alignment=None):p = doc.add_paragraph(style=style) if style else doc.add_paragraph()if alignment is not None:p.alignment = alignmentrun = p.add_run(text)_style_run(run, font=font, size=size, bold=bold, italic=italic, color=color)return pdef _filename(title, mm_yyyy, initials, draft=False, template=False):"""Build the Meridian SOP filename. mm_yyyy uses '/' inside; filename uses '-'."""safe_title = title.strip()# Filename uses dash between month and yearmm_dash_yyyy = mm_yyyy.replace("/", "-")name = f"SOP_{safe_title}_{mm_dash_yyyy}_{initials}"if template:name = f"SOP_{safe_title}_TEMPLATE_{mm_dash_yyyy}_{initials}"if draft:name = f"DRAFT_{name}"return f"{name}.docx"def _build_title_bar(doc, title, folder_context):"""1-cell table at the top: 'SOP | <Title>' on line 1, folder context on line 2."""table = doc.add_table(rows=1, cols=1)table.autofit = Truecell = table.cell(0, 0)_set_cell_background(cell, "EAF2F8") # very light blue tint# Wipe the default paragraphcell.text = ""p1 = cell.paragraphs[0]r1 = p1.add_run(f"SOP | {title}")_style_run(r1, font=BODY_FONT, size=16, bold=True, color=MERIDIAN_NAVY)if folder_context:p2 = cell.add_paragraph()r2 = p2.add_run(folder_context)_style_run(r2, font=CODE_FONT, size=10, bold=False, color=MERIDIAN_NAVY)return tabledef _build_metadata_table(doc, filename, category, owner, effective_date):"""4-column header metadata table."""table = doc.add_table(rows=2, cols=4)_apply_table_borders(table)headers = ["File Name", "Category", "Owner", "Effective Date"]values = [filename.replace(".docx", ""), category, owner, effective_date]for ci, (h, v) in enumerate(zip(headers, values)):hcell = table.cell(0, ci)hcell.text = ""hp = hcell.paragraphs[0]hr = hp.add_run(h)_style_run(hr, font=BODY_FONT, size=11, bold=True, color=BODY_BLACK)_set_cell_background(hcell, "D6EAF8")vcell = table.cell(1, ci)vcell.text = ""vp = vcell.paragraphs[0]vr = vp.add_run(v)_style_run(vr, font=BODY_FONT, size=11, bold=False, color=BODY_BLACK)return tabledef _add_blank(doc):doc.add_paragraph()def _render_section(doc, sec):t = sec.get("type")text = sec.get("text", "")if t == "purpose":h = doc.add_heading("Purpose", level=1)_add_styled_para(doc, text, font=BODY_FONT, size=11)elif t == "scope":h = doc.add_heading("Scope & Responsibility", level=1)_add_styled_para(doc, text, font=BODY_FONT, size=11)elif t == "h1":doc.add_heading(text, level=1)elif t == "h2":doc.add_heading(text, level=2)elif t == "h3":doc.add_heading(text, level=3)elif t == "body":_add_styled_para(doc, text, font=BODY_FONT, size=11)elif t == "accent_subhead":_add_styled_para(doc, text, font=BODY_FONT, size=12, bold=True, color=MERIDIAN_NAVY)elif t == "bullet":p = doc.add_paragraph()p.paragraph_format.left_indent = Inches(0.25)r = p.add_run(f"• {text}")_style_run(r, font=BODY_FONT, size=11)elif t == "numbered":# Number gets auto-incremented within a 'numbered' run via state attached to the docn = getattr(doc, "_sop_num_counter", 0) + 1setattr(doc, "_sop_num_counter", n)p = doc.add_paragraph()p.paragraph_format.left_indent = Inches(0.25)r = p.add_run(f"{n}. {text}")_style_run(r, font=BODY_FONT, size=11)elif t == "code":# Multi-line code or path block, monospace bluefor line in text.split("\n"):p = doc.add_paragraph()r = p.add_run(line if line else " ")_style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)elif t == "format_spec":# 1-cell table with the format pattern, monospace blue boldtable = doc.add_table(rows=1, cols=1)_apply_table_borders(table)cell = table.cell(0, 0)_set_cell_background(cell, "EAF2F8")cell.text = ""for i, line in enumerate(text.split("\n")):if i == 0:p = cell.paragraphs[0]else:p = cell.add_paragraph()r = p.add_run(line)_style_run(r, font=CODE_FONT, size=11, bold=True, color=MERIDIAN_NAVY)elif t == "components_table":rows = sec["rows"]table = doc.add_table(rows=len(rows), cols=len(rows[0]))_apply_table_borders(table)for ri, row in enumerate(rows):for ci, val in enumerate(row):cell = table.cell(ri, ci)cell.text = ""p = cell.paragraphs[0]r = p.add_run(str(val))is_header = (ri == 0)if is_header:_style_run(r, font=BODY_FONT, size=11, bold=True, color=BODY_BLACK)_set_cell_background(cell, "D6EAF8")else:# First column often holds the code-style identifier; render second column as codeif ci == 1:_style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)else:_style_run(r, font=BODY_FONT, size=11, color=BODY_BLACK)elif t == "examples_table":rows = sec["rows"]table = doc.add_table(rows=len(rows), cols=len(rows[0]))_apply_table_borders(table)for ri, row in enumerate(rows):for ci, val in enumerate(row):cell = table.cell(ri, ci)cell.text = ""p = cell.paragraphs[0]r = p.add_run(str(val))is_header = (ri == 0)last_col = (ci == len(row) - 1)if is_header:_style_run(r, font=BODY_FONT, size=11, bold=True, color=BODY_BLACK)_set_cell_background(cell, "D6EAF8")else:if last_col:_style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)else:_style_run(r, font=BODY_FONT, size=11, color=BODY_BLACK)elif t == "note":# Italic gray note, full-width 1-cell tabletable = doc.add_table(rows=1, cols=1)cell = table.cell(0, 0)_set_cell_background(cell, "FCF3CF") # light yellow note tintcell.text = ""p = cell.paragraphs[0]r = p.add_run(text)_style_run(r, font=BODY_FONT, size=10, italic=True, color=GRAY)elif t == "warning":# Red bold callout_add_styled_para(doc, text, font=BODY_FONT, size=12, bold=True, color=WARN_RED)elif t == "quick_ref":# 1-cell table styled like a reference card. Monospace, multi-line.table = doc.add_table(rows=1, cols=1)cell = table.cell(0, 0)_set_cell_background(cell, "EAF2F8")cell.text = ""for i, line in enumerate(text.split("\n")):if i == 0:p = cell.paragraphs[0]else:p = cell.add_paragraph()r = p.add_run(line if line else " ")_style_run(r, font=CODE_FONT, size=10, color=MERIDIAN_NAVY)elif t == "blank":_add_blank(doc)else:# Unknown type: render as plain body but flag in consoleprint(f"WARN: unknown section type '{t}', rendering as body", file=sys.stderr)_add_styled_para(doc, text, font=BODY_FONT, size=11)def build_sop(spec):"""Build a Meridian SOP .docx from a spec dict.Required keys:title (str) : the process titleowner (str) : full name of the ownerinitials (str) : author initials, e.g. "MG"category (str) : Operations / Design / etc.effective_date (str) : "MM/YYYY"folder_context (str) : the Shared Drive path / scope, e.g. "00 - SOPs > 02 - Operations"sections (list[dict]) : the body, see section schemas aboveoutput_path (str|Path) : where to save the .docxOptional keys:draft (bool) : prefix filename with DRAFT_template (bool) : insert _TEMPLATE before the datepage_margin_inches (float) : defaults to 1.0"""if TEMPLATE_PATH.exists():# Copy template to a scratch path, then opendoc = Document(str(TEMPLATE_PATH))# Wipe everything in the template body: we want the styles, not the contentfor element in list(doc.element.body):doc.element.body.remove(element)# Re-add a sectPr at the end (required by Word)from docx.oxml import OxmlElementsectPr = OxmlElement("w:sectPr")doc.element.body.append(sectPr)else:doc = Document()margin = Inches(spec.get("page_margin_inches", 1.0))for section in doc.sections:section.top_margin = marginsection.bottom_margin = marginsection.left_margin = marginsection.right_margin = margintitle = spec["title"]owner = spec["owner"]initials = spec["initials"]category = spec["category"]effective_date = spec["effective_date"] # MM/YYYYfolder_context = spec.get("folder_context", "")sections = spec.get("sections", [])filename = _filename(title, effective_date, initials,draft=spec.get("draft", False),template=spec.get("template", False),)_build_title_bar(doc, title, folder_context)_add_blank(doc)_build_metadata_table(doc, filename, category, owner, effective_date)_add_blank(doc)for sec in sections:_render_section(doc, sec)out = spec.get("output_path")if out is None:out = filenameout_path = Path(out)if out_path.is_dir():out_path = out_path / filenameelif not str(out_path).endswith(".docx"):out_path = Path(str(out_path) + ".docx")# If the user passed a directory or generic name, override with the canonical filenameif out_path.name != filename and spec.get("force_filename", True):out_path = out_path.parent / filenamedoc.save(str(out_path))return str(out_path)def validate(path):"""Quick structural check of a Meridian SOP."""doc = Document(path)findings = []# Filename checkfn = Path(path).nameif not re.match(r"^(DRAFT_)?SOP_.+_(TEMPLATE_)?\d{2}-\d{4}_[A-Z]{2,3}\.docx$", fn):findings.append(f"FAIL: filename '{fn}' does not match SOP_<Title>_<MM-YYYY>_<Initials>.docx pattern")# Title bar (first table, 1 cell, contains 'SOP |')if not doc.tables:findings.append("FAIL: no tables found, title bar missing")else:first = doc.tables[0]if first.rows[0].cells[0].text.startswith("SOP |") or first.rows[0].cells[0].text.startswith("SOP|"):findings.append("PASS: title bar present")else:findings.append(f"FAIL: first table is not a title bar (content: {first.rows[0].cells[0].text[:60]!r})")# Metadata table (4-col, headers File Name / Category / Owner / Effective Date)meta_ok = Falsefor table in doc.tables[:3]:if len(table.columns) == 4 and len(table.rows) >= 2:headers = [c.text.strip() for c in table.rows[0].cells]if headers == ["File Name", "Category", "Owner", "Effective Date"]:meta_ok = Truebreakfindings.append("PASS: metadata table present" if meta_ok else "FAIL: metadata table missing or wrong shape")# Required Heading 1 sectionsh1_texts = [p.text for p in doc.paragraphs if p.style and p.style.name == "Heading 1"]for required in ("Purpose", "Scope & Responsibility"):if any(required in t for t in h1_texts):findings.append(f"PASS: '{required}' section present")else:findings.append(f"FAIL: '{required}' section missing")# Quick Reference Cardif any("Quick Reference" in t for t in h1_texts):findings.append("PASS: Quick Reference Card present")else:findings.append("WARN: Quick Reference Card missing (recommended but not strictly required)")return findingsdef _cli():parser = argparse.ArgumentParser(description="Meridian SOP builder")parser.add_argument("--validate", metavar="FILE", help="Validate an existing .docx against Meridian SOP shape")args = parser.parse_args()if args.validate:findings = validate(args.validate)for f in findings:print(f)fails = [f for f in findings if f.startswith("FAIL")]sys.exit(1 if fails else 0)else:parser.print_help()if __name__ == "__main__":_cli()