kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

, '', s) # strip \"x2\", \"x3\" multiplier\n parts = s.split()\n if len(parts) >= 2 and parts[1] and parts[1][0].lower() in \"pnuµ\":\n s = parts[0] + parts[1]\n else:\n s = parts[0] if parts else \"\"\n s = s.rstrip(\"Ff\")\n if not s:\n return None\n # Trailing SI prefix\n if s[-1] in _SI_PREFIXES:\n mult = _SI_PREFIXES[s[-1]]\n try:\n return float(s[:-1]) * mult\n except ValueError:\n return None\n # Embedded prefix: \"4u7\" -> 4.7e-6\n for ch, mult in _SI_PREFIXES.items():\n if ch in s and not s.endswith(ch):\n idx = s.index(ch)\n before, after = s[:idx], s[idx + 1:]\n if before.replace(\".\", \"\").isdigit() and after.isdigit():\n try:\n return float(f\"{before}.{after}\") * mult\n except ValueError:\n pass\n # Bare number — assume farads (caller should know context)\n try:\n return float(s)\n except ValueError:\n return None\n\n\ndef _parse_inductance(text: str) -> float | None:\n \"\"\"Parse an inductance string like '10uH', '1uH' to henries.\"\"\"\n if not text:\n return None\n s = text.strip()\n # Strip current rating and other suffixes\n s = re.sub(r',.*', '', s)\n s = re.sub(r'\\s+Isat.*', '', s, flags=re.I)\n parts = s.split()\n if len(parts) >= 2 and parts[1] and parts[1][0].lower() in \"pnuµm\":\n s = parts[0] + parts[1]\n else:\n s = parts[0] if parts else \"\"\n s = s.rstrip(\"Hh\")\n if not s:\n return None\n if s[-1] in _SI_PREFIXES:\n mult = _SI_PREFIXES[s[-1]]\n try:\n return float(s[:-1]) * mult\n except ValueError:\n return None\n try:\n return float(s)\n except ValueError:\n return None\n\n\ndef _cap_multiplier(text: str) -> int:\n \"\"\"Extract a 'xN' multiplier from a recommendation string like '47uF x2'.\"\"\"\n m = re.search(r'x\\s*(\\d+)', text, re.I)\n return int(m.group(1)) if m else 1\n\n\ndef _format_farads(f: float) -> str:\n \"\"\"Format farads as a human-readable string.\"\"\"\n if f >= 1e-3:\n return f\"{f * 1e3:.0f}mF\"\n if f >= 1e-6:\n v = f * 1e6\n return f\"{v:.1f}\\u00b5F\" if v != int(v) else f\"{int(v)}\\u00b5F\"\n if f >= 1e-9:\n v = f * 1e9\n return f\"{v:.1f}nF\" if v != int(v) else f\"{int(v)}nF\"\n v = f * 1e12\n return f\"{v:.1f}pF\" if v != int(v) else f\"{int(v)}pF\"\n\n\ndef _format_henries(h: float) -> str:\n \"\"\"Format henries as a human-readable string.\"\"\"\n if h >= 1e-3:\n v = h * 1e3\n return f\"{v:.1f}mH\" if v != int(v) else f\"{int(v)}mH\"\n if h >= 1e-6:\n v = h * 1e6\n return f\"{v:.1f}\\u00b5H\" if v != int(v) else f\"{int(v)}\\u00b5H\"\n v = h * 1e9\n return f\"{v:.1f}nH\" if v != int(v) else f\"{int(v)}nH\"\n\n\n# ---------------------------------------------------------------------------\n# 1. load_project_extractions\n# ---------------------------------------------------------------------------\n\ndef load_project_extractions(project_dir: str,\n analysis: dict) -> dict[str, dict]:\n \"\"\"Load datasheet extractions for components in the analysis.\n\n Args:\n project_dir: KiCad project directory (contains datasheets/extracted/)\n analysis: loaded schematic analysis JSON\n\n Returns:\n dict mapping MPN -> extraction dict. Only includes MPNs that\n have cached extractions.\n \"\"\"\n extract_dir = resolve_extract_dir(project_dir=project_dir)\n\n # Collect unique MPNs from all components\n mpns: set[str] = set()\n for comp in analysis.get(\"components\", []):\n for key in (\"mpn\", \"mfg_part\", \"MPN\"):\n val = comp.get(key, \"\")\n if val and isinstance(val, str) and val.strip():\n mpns.add(val.strip())\n break\n # Also check properties dict if present\n props = comp.get(\"properties\", {})\n if isinstance(props, dict):\n for key in (\"MPN\", \"mpn\", \"Mfg Part\", \"mfg_part\", \"Part Number\"):\n val = props.get(key, \"\")\n if val and isinstance(val, str) and val.strip():\n mpns.add(val.strip())\n break\n\n # Load cached extractions\n result: dict[str, dict] = {}\n for mpn in mpns:\n extraction = get_cached_extraction(extract_dir, mpn)\n if extraction is not None:\n result[mpn] = extraction\n\n return result\n\n\n# ---------------------------------------------------------------------------\n# 2. build_component_comparison\n# ---------------------------------------------------------------------------\n\ndef _find_caps_on_net(analysis: dict, net_name: str) -> list[dict]:\n \"\"\"Find all capacitors connected to a given net in the analysis.\n\n Returns list of dicts with 'ref', 'value', 'farads'.\n \"\"\"\n caps = []\n if not net_name:\n return caps\n nets = analysis.get(\"nets\", {})\n net_info = nets.get(net_name)\n if not net_info:\n return caps\n comp_lookup = {}\n for c in analysis.get(\"components\", []):\n comp_lookup[c[\"reference\"]] = c\n for pin in net_info.get(\"pins\", []):\n comp = comp_lookup.get(pin[\"component\"])\n if comp and comp.get(\"type\") == \"capacitor\":\n val = comp.get(\"value\", \"\")\n farads = _parse_cap_value(val)\n caps.append({\n \"ref\": pin[\"component\"],\n \"value\": val,\n \"farads\": farads,\n })\n return caps\n\n\ndef _find_inductor_for_regulator(analysis: dict, reg: dict) -> dict | None:\n \"\"\"Find the inductor associated with a power regulator.\"\"\"\n ind_ref = reg.get(\"inductor\")\n if not ind_ref:\n return None\n for comp in analysis.get(\"components\", []):\n if comp[\"reference\"] == ind_ref:\n val = comp.get(\"value\", \"\")\n henries = _parse_inductance(val)\n return {\"ref\": ind_ref, \"value\": val, \"henries\": henries}\n return None\n\n\ndef _compare_capacitance(actual_farads: float | None,\n recommended_text: str) -> tuple[str, str]:\n \"\"\"Compare actual capacitance against a recommendation string.\n\n Returns (status, note).\n \"\"\"\n rec_farads = _parse_cap_value(recommended_text)\n rec_count = _cap_multiplier(recommended_text)\n if rec_farads is not None:\n rec_total = rec_farads * rec_count\n else:\n rec_total = None\n\n if actual_farads is None:\n return \"missing\", \"No capacitor found on this net\"\n\n if rec_total is None:\n # Can't parse recommendation — assume ok if something is present\n return \"ok\", \"Capacitor present (could not parse recommendation)\"\n\n ratio = actual_farads / rec_total if rec_total > 0 else 0\n if ratio >= 0.9:\n return \"ok\", \"\"\n elif ratio >= 0.4:\n return \"warning\", f\"Below recommended ({_format_farads(actual_farads)} vs {_format_farads(rec_total)})\"\n else:\n return \"mismatch\", f\"Significantly below ({_format_farads(actual_farads)} vs {_format_farads(rec_total)})\"\n\n\ndef _compare_inductance(actual_henries: float | None,\n recommended_text: str) -> tuple[str, str]:\n \"\"\"Compare actual inductance against a recommendation string.\n\n Returns (status, note).\n \"\"\"\n rec_henries = _parse_inductance(recommended_text)\n\n if actual_henries is None:\n return \"missing\", \"No inductor found\"\n\n if rec_henries is None:\n return \"ok\", \"Inductor present (could not parse recommendation)\"\n\n ratio = actual_henries / rec_henries if rec_henries > 0 else 0\n # Inductors can vary widely; 0.5x-3x is generally acceptable\n if 0.5 \u003c= ratio \u003c= 3.0:\n return \"ok\", \"\"\n elif 0.2 \u003c= ratio \u003c 0.5 or 3.0 \u003c ratio \u003c= 5.0:\n return \"warning\", f\"Outside typical range ({_format_henries(actual_henries)} vs {_format_henries(rec_henries)} recommended)\"\n else:\n return \"mismatch\", f\"Significantly different ({_format_henries(actual_henries)} vs {_format_henries(rec_henries)} recommended)\"\n\n\ndef build_component_comparison(analysis: dict,\n extractions: dict) -> list[dict]:\n \"\"\"Compare actual components vs datasheet recommendations.\n\n For each IC with an extraction, check:\n - Input cap: does the schematic have the recommended input capacitance?\n - Output cap: does the schematic have the recommended output capacitance?\n - Inductor: is the inductor value within the recommended range?\n - Feedback resistors: do they produce the correct Vout given Vref?\n\n Returns list of comparison dicts:\n {\n 'ref': 'U1',\n 'mpn': 'TPS54360',\n 'parameter': 'Input Capacitor',\n 'recommended': '10uF ceramic',\n 'actual': 'C1: 4.7uF',\n 'status': 'warning', # ok | warning | mismatch | missing\n 'note': 'Below recommended value'\n }\n \"\"\"\n comparisons: list[dict] = []\n regulators = [f for f in analysis.get(\"findings\", [])\n if f.get(\"detector\") == \"detect_power_regulators\"]\n\n # Build ref -> regulator lookup\n reg_by_ref: dict[str, dict] = {}\n for reg in regulators:\n reg_by_ref[reg.get(\"ref\", \"\")] = reg\n\n # Build ref -> MPN lookup from components\n ref_to_mpn: dict[str, str] = {}\n for comp in analysis.get(\"components\", []):\n for key in (\"mpn\", \"mfg_part\", \"MPN\"):\n val = comp.get(key, \"\")\n if val and isinstance(val, str) and val.strip():\n ref_to_mpn[comp[\"reference\"]] = val.strip()\n break\n\n # For each regulator with an extraction\n for reg in regulators:\n ref = reg.get(\"ref\", \"\")\n mpn = ref_to_mpn.get(ref, \"\")\n extraction = extractions.get(mpn)\n if not extraction:\n continue\n\n app = extraction.get(\"application_circuit\")\n if not app:\n continue\n\n # --- Input capacitor check ---\n input_cap_rec = app.get(\"input_cap_recommended\")\n if input_cap_rec:\n input_rail = reg.get(\"input_rail\")\n input_caps = _find_caps_on_net(analysis, input_rail)\n total_farads = sum(c[\"farads\"] for c in input_caps if c[\"farads\"])\n if input_caps:\n actual_str = \", \".join(\n f\"{c['ref']}: {c['value']}\" for c in input_caps)\n else:\n actual_str = \"—\"\n total_farads = None\n\n status, note = _compare_capacitance(total_farads, input_cap_rec)\n comparisons.append({\n \"ref\": ref,\n \"mpn\": mpn,\n \"parameter\": \"Input Capacitor\",\n \"recommended\": input_cap_rec,\n \"actual\": actual_str,\n \"status\": status,\n \"note\": note,\n })\n\n # --- Output capacitor check ---\n output_cap_rec = app.get(\"output_cap_recommended\")\n if output_cap_rec:\n output_rail = reg.get(\"output_rail\")\n output_caps = _find_caps_on_net(analysis, output_rail)\n total_farads = sum(c[\"farads\"] for c in output_caps if c[\"farads\"])\n if output_caps:\n actual_str = \", \".join(\n f\"{c['ref']}: {c['value']}\" for c in output_caps)\n else:\n actual_str = \"—\"\n total_farads = None\n\n status, note = _compare_capacitance(total_farads, output_cap_rec)\n comparisons.append({\n \"ref\": ref,\n \"mpn\": mpn,\n \"parameter\": \"Output Capacitor\",\n \"recommended\": output_cap_rec,\n \"actual\": actual_str,\n \"status\": status,\n \"note\": note,\n })\n\n # --- Inductor check ---\n ind_rec = app.get(\"inductor_recommended\")\n if ind_rec:\n inductor = _find_inductor_for_regulator(analysis, reg)\n if inductor:\n actual_str = f\"{inductor['ref']}: {inductor['value']}\"\n actual_henries = inductor.get(\"henries\")\n else:\n actual_str = \"—\"\n actual_henries = None\n\n status, note = _compare_inductance(actual_henries, ind_rec)\n comparisons.append({\n \"ref\": ref,\n \"mpn\": mpn,\n \"parameter\": \"Inductor\",\n \"recommended\": ind_rec,\n \"actual\": actual_str,\n \"status\": status,\n \"note\": note,\n })\n\n # --- Feedback resistor / Vout check ---\n vout_formula = app.get(\"vout_formula\")\n e_chars = extraction.get(\"electrical_characteristics\", {})\n vref = e_chars.get(\"vref_v\")\n if vref and reg.get(\"estimated_vout\") and reg.get(\"feedback_divider\"):\n fb = reg[\"feedback_divider\"]\n r_top = fb.get(\"r_top\", {})\n r_bottom = fb.get(\"r_bottom\", {})\n actual_str = (\n f\"R_top={r_top.get('ref', '?')} ({r_top.get('value', '?')}), \"\n f\"R_bot={r_bottom.get('ref', '?')} ({r_bottom.get('value', '?')})\"\n )\n est_vout = reg[\"estimated_vout\"]\n rec_str = f\"Vref={vref}V\"\n if vout_formula:\n rec_str += f\", {vout_formula}\"\n\n # Check if output voltage is reasonable\n rec_vout = extraction.get(\"recommended_operating_conditions\", {})\n vout_max = rec_vout.get(\"vout_max_v\")\n vout_min = rec_vout.get(\"vout_min_v\")\n\n if vout_max and est_vout > vout_max * 1.05:\n status = \"mismatch\"\n note = f\"Estimated Vout ({est_vout:.2f}V) exceeds max ({vout_max}V)\"\n elif vout_min and est_vout \u003c vout_min * 0.95:\n status = \"warning\"\n note = f\"Estimated Vout ({est_vout:.2f}V) below min ({vout_min}V)\"\n else:\n status = \"ok\"\n note = f\"Estimated Vout = {est_vout:.2f}V\"\n\n comparisons.append({\n \"ref\": ref,\n \"mpn\": mpn,\n \"parameter\": \"Feedback / Vout\",\n \"recommended\": rec_str,\n \"actual\": actual_str,\n \"status\": status,\n \"note\": note,\n })\n\n return comparisons\n\n\n# ---------------------------------------------------------------------------\n# 3. build_pin_audit\n# ---------------------------------------------------------------------------\n\ndef _find_connected_components(analysis: dict, ic_ref: str,\n pin_number: str) -> list[dict]:\n \"\"\"Find components connected to a specific IC pin.\n\n Returns list of dicts with 'ref', 'value', 'type'.\n \"\"\"\n result = []\n ic_analysis = analysis.get(\"ic_pin_analysis\", [])\n for ic in ic_analysis:\n if ic.get(\"reference\") != ic_ref:\n continue\n for pin in ic.get(\"pins\", []):\n if str(pin.get(\"pin_number\")) != str(pin_number):\n continue\n # Parse connected_to list\n for conn in pin.get(\"connected_to\", []):\n if isinstance(conn, dict):\n result.append({\n \"ref\": conn.get(\"ref\", conn.get(\"component\", \"\")),\n \"value\": conn.get(\"value\", \"\"),\n \"type\": conn.get(\"type\", \"\"),\n })\n elif isinstance(conn, str):\n # Format: \"R1 (10K)\" or just \"R1\"\n m = re.match(r'(\\S+)\\s*\\(([^)]*)\\)', conn)\n if m:\n result.append({\"ref\": m.group(1), \"value\": m.group(2), \"type\": \"\"})\n else:\n result.append({\"ref\": conn.strip(), \"value\": \"\", \"type\": \"\"})\n break\n break\n return result\n\n\ndef _get_pin_net(analysis: dict, ic_ref: str, pin_number: str) -> str | None:\n \"\"\"Get the net name connected to a specific IC pin.\"\"\"\n ic_analysis = analysis.get(\"ic_pin_analysis\", [])\n for ic in ic_analysis:\n if ic.get(\"reference\") != ic_ref:\n continue\n for pin in ic.get(\"pins\", []):\n if str(pin.get(\"pin_number\")) != str(pin_number):\n continue\n net = pin.get(\"net\", \"\")\n if net and net not in (\"UNCONNECTED\", \"NO_CONNECT\"):\n return net\n return None\n break\n return None\n\n\ndef _fuzzy_match_requirement(required_text: str,\n connected: list[dict],\n net_name: str | None) -> tuple[str, str]:\n \"\"\"Fuzzy-match a required_external description against actual connections.\n\n Returns (status, summary_string).\n \"\"\"\n req_lower = required_text.lower()\n\n # Handle \"no connect\" / \"do not connect\" requirements\n nc_keywords = (\"no connect\", \"do not connect\", \"nc pin\", \"leave open\",\n \"leave floating\")\n if any(kw in req_lower for kw in nc_keywords):\n if not connected and (net_name is None or net_name == \"\"):\n return \"nc_ok\", \"Not connected (correct)\"\n elif connected:\n names = \", \".join(c[\"ref\"] for c in connected if c[\"ref\"])\n return \"warning\", f\"Connected to {names} but datasheet says NC\"\n return \"nc_ok\", \"Not connected (correct)\"\n\n # Handle \"do not float\" — pin must be connected to something\n if \"do not float\" in req_lower or \"must not float\" in req_lower:\n if connected or net_name:\n summary = \", \".join(\n f\"{c['ref']} ({c['value']})\" if c.get(\"value\") else c['ref']\n for c in connected\n ) if connected else (net_name or \"\")\n return \"ok\", summary\n return \"missing\", \"Pin is floating but datasheet says do not float\"\n\n # Build summary of what's connected\n summary_parts = []\n for c in connected:\n if c.get(\"value\"):\n summary_parts.append(f\"{c['ref']} ({c['value']})\")\n elif c.get(\"ref\"):\n summary_parts.append(c['ref'])\n if not summary_parts and net_name:\n summary_parts.append(net_name)\n summary = \", \".join(summary_parts)\n\n # Check for ground connection requirements first — a GND pin with\n # \"connect to ground plane\" is satisfied by the net being GND even\n # when no discrete components appear in connected_to.\n ground_nets = (\"GND\", \"VSS\", \"AGND\", \"DGND\", \"PGND\")\n if \"ground\" in req_lower or \"gnd\" in req_lower or \"vss\" in req_lower:\n if net_name and net_name.upper() in ground_nets:\n return \"ok\", net_name\n if connected:\n return \"ok\", summary\n return \"missing\", f\"Requires ground connection: {required_text}\"\n\n # Check for power connection requirements\n power_kw = (\"vcc\", \"vdd\", \"power\", \"supply\", \"vin\", \"vbus\")\n if any(kw in req_lower for kw in power_kw):\n if net_name and any(kw in net_name.upper() for kw in\n (\"VCC\", \"VDD\", \"+3\", \"+5\", \"+12\", \"VBUS\", \"VIN\")):\n return \"ok\", net_name\n if connected:\n return \"ok\", summary\n return \"missing\", f\"Requires power connection: {required_text}\"\n\n # Nothing connected and not a ground/power pin — missing\n if not connected and not net_name:\n return \"missing\", \"Not connected\"\n\n # Check for capacitor requirements\n cap_match = re.search(r'(\\d+(?:\\.\\d+)?)\\s*([pnuµ])[Ff]?', req_lower)\n if cap_match or \"cap\" in req_lower or \"bypass\" in req_lower or \"decouple\" in req_lower:\n has_cap = any(c.get(\"ref\", \"\").startswith(\"C\") or\n c.get(\"type\", \"\") == \"capacitor\"\n for c in connected)\n if has_cap:\n return \"ok\", summary\n return \"missing\", f\"Requires capacitor: {required_text}\"\n\n # Check for resistor requirements (pull-up, pull-down, series)\n res_match = re.search(r'(\\d+(?:\\.\\d+)?)\\s*([kKMR])\\s*(pull|resistor|ohm)', req_lower)\n if res_match or \"pull-up\" in req_lower or \"pull-down\" in req_lower or \"pullup\" in req_lower:\n has_res = any(c.get(\"ref\", \"\").startswith(\"R\") or\n c.get(\"type\", \"\") == \"resistor\"\n for c in connected)\n if has_res:\n return \"ok\", summary\n return \"missing\", f\"Requires resistor: {required_text}\"\n\n # Check for inductor requirements\n if \"inductor\" in req_lower or \"coil\" in req_lower or \"ferrite\" in req_lower:\n has_ind = any(c.get(\"ref\", \"\").startswith((\"L\", \"FB\")) or\n c.get(\"type\", \"\") in (\"inductor\", \"ferrite_bead\")\n for c in connected)\n if has_ind:\n return \"ok\", summary\n return \"missing\", f\"Requires inductor: {required_text}\"\n\n # Generic: if something is connected, assume ok\n if connected or net_name:\n return \"ok\", summary\n\n return \"missing\", f\"Not connected — {required_text}\"\n\n\ndef build_pin_audit(analysis: dict,\n extractions: dict) -> list[dict]:\n \"\"\"Audit pin connections against datasheet requirements.\n\n For each IC pin with 'required_external' in the extraction,\n check if the schematic has the required connection.\n\n Returns list of audit entries:\n {\n 'ref': 'U1',\n 'mpn': 'TPS54360',\n 'pin_number': '1',\n 'pin_name': 'BOOT',\n 'required': '100nF bootstrap cap to SW pin',\n 'connected_to': 'C5 (100nF)',\n 'status': 'ok', # ok | warning | missing | nc_ok\n }\n \"\"\"\n audits: list[dict] = []\n\n # Build ref -> MPN lookup\n ref_to_mpn: dict[str, str] = {}\n for comp in analysis.get(\"components\", []):\n for key in (\"mpn\", \"mfg_part\", \"MPN\"):\n val = comp.get(key, \"\")\n if val and isinstance(val, str) and val.strip():\n ref_to_mpn[comp[\"reference\"]] = val.strip()\n break\n\n # Build ic_ref set from ic_pin_analysis\n ic_refs: dict[str, dict] = {}\n for ic in analysis.get(\"ic_pin_analysis\", []):\n ic_refs[ic.get(\"reference\", \"\")] = ic\n\n for ref, mpn in ref_to_mpn.items():\n extraction = extractions.get(mpn)\n if not extraction:\n continue\n\n pins = extraction.get(\"pins\", [])\n if not pins:\n continue\n\n for ext_pin in pins:\n required = ext_pin.get(\"required_external\")\n if not required:\n continue\n\n pin_number = str(ext_pin.get(\"number\", \"\"))\n pin_name = ext_pin.get(\"name\", \"\")\n\n # Find what's connected to this pin in the schematic\n connected = _find_connected_components(analysis, ref, pin_number)\n net_name = _get_pin_net(analysis, ref, pin_number)\n\n status, connected_summary = _fuzzy_match_requirement(\n required, connected, net_name)\n\n audits.append({\n \"ref\": ref,\n \"mpn\": mpn,\n \"pin_number\": pin_number,\n \"pin_name\": pin_name,\n \"required\": required,\n \"connected_to\": connected_summary,\n \"status\": status,\n })\n\n return audits\n\n\n# ---------------------------------------------------------------------------\n# 4. build_spec_summary\n# ---------------------------------------------------------------------------\n\n_CATEGORY_SPEC_KEYS: dict[str, list[tuple[str, str, str]]] = {\n # (json_key, display_label, section) where section is which top-level dict to look in\n \"switching_regulator\": [\n (\"vin_min_v\", \"Vin Min\", \"roc\"),\n (\"vin_max_v\", \"Vin Max\", \"roc\"),\n (\"vref_v\", \"Vref\", \"ec\"),\n (\"switching_frequency_khz\", \"Freq\", \"ec\"),\n (\"quiescent_current_ua\", \"Iq\", \"ec\"),\n (\"output_current_max_ma\", \"Iout Max\", \"ec\"),\n (\"junction_temp_max_c\", \"Tj Max\", \"amr\"),\n ],\n \"linear_regulator\": [\n (\"vin_min_v\", \"Vin Min\", \"roc\"),\n (\"vin_max_v\", \"Vin Max\", \"roc\"),\n (\"dropout_mv\", \"Dropout\", \"ec\"),\n (\"output_current_max_a\", \"Iout Max\", \"ec\"),\n (\"quiescent_current_ua\", \"Iq\", \"ec\"),\n (\"junction_temp_max_c\", \"Tj Max\", \"amr\"),\n ],\n \"operational_amplifier\": [\n (\"gbw_hz\", \"GBW\", \"ec\"),\n (\"slew_vus\", \"Slew Rate\", \"ec\"),\n (\"vos_mv\", \"Vos\", \"ec\"),\n (\"aol_db\", \"Aol\", \"ec\"),\n (\"rin_ohms\", \"Rin\", \"ec\"),\n (\"cmrr_db\", \"CMRR\", \"ec\"),\n ],\n \"comparator\": [\n (\"propagation_delay_ns\", \"tPD\", \"ec\"),\n (\"vos_mv\", \"Vos\", \"ec\"),\n (\"vin_min_v\", \"Vin Min\", \"roc\"),\n (\"vin_max_v\", \"Vin Max\", \"roc\"),\n ],\n \"microcontroller\": [\n (\"vin_min_v\", \"Vin Min\", \"roc\"),\n (\"vin_max_v\", \"Vin Max\", \"roc\"),\n (\"quiescent_current_ua\", \"Iq\", \"ec\"),\n (\"io_voltage_max\", \"I/O Max V\", \"amr\"),\n (\"junction_temp_max_c\", \"Tj Max\", \"amr\"),\n (\"temp_min_c\", \"Temp Min\", \"roc\"),\n (\"temp_max_c\", \"Temp Max\", \"roc\"),\n ],\n \"esd_protection\": [\n (\"clamping_voltage_v\", \"Vclamp\", \"ec\"),\n (\"capacitance_pf\", \"Cpara\", \"ec\"),\n (\"esd_hbm_v\", \"ESD HBM\", \"amr\"),\n (\"esd_cdm_v\", \"ESD CDM\", \"amr\"),\n ],\n \"voltage_reference\": [\n (\"vref_v\", \"Vref\", \"ec\"),\n (\"vref_accuracy_pct\", \"Accuracy\", \"ec\"),\n (\"temp_coeff_ppm_c\", \"TC\", \"ec\"),\n ],\n}\n\n# Default spec keys for unknown categories\n_DEFAULT_SPEC_KEYS: list[tuple[str, str, str]] = [\n (\"vin_min_v\", \"Vin Min\", \"roc\"),\n (\"vin_max_v\", \"Vin Max\", \"roc\"),\n (\"junction_temp_max_c\", \"Tj Max\", \"amr\"),\n (\"temp_min_c\", \"Temp Min\", \"roc\"),\n (\"temp_max_c\", \"Temp Max\", \"roc\"),\n]\n\n\ndef _format_spec_value(key: str, value) -> str:\n \"\"\"Format a spec value for display based on its key name.\"\"\"\n if value is None:\n return \"\\u2014\"\n if \"temp\" in key or key.endswith(\"_c\"):\n return f\"{value}\\u00b0C\"\n if key.endswith(\"_v\") or key == \"io_voltage_max\":\n return f\"{value}V\"\n if key.endswith(\"_mv\"):\n return f\"{value}mV\"\n if key.endswith(\"_ma\"):\n return f\"{value}mA\"\n if key.endswith(\"_a\") and not key.endswith(\"_ua\"):\n return f\"{value}A\"\n if key.endswith(\"_ua\"):\n return f\"{value}\\u00b5A\"\n if key.endswith(\"_khz\"):\n return f\"{value}kHz\"\n if key.endswith(\"_hz\"):\n if value >= 1e6:\n return f\"{value / 1e6:.1f}MHz\"\n if value >= 1e3:\n return f\"{value / 1e3:.1f}kHz\"\n return f\"{value}Hz\"\n if key.endswith(\"_db\"):\n return f\"{value}dB\"\n if key.endswith(\"_pct\"):\n return f\"\\u00b1{value}%\"\n if key.endswith(\"_ohms\"):\n if value >= 1e6:\n return f\"{value / 1e6:.1f}M\\u03a9\"\n if value >= 1e3:\n return f\"{value / 1e3:.1f}k\\u03a9\"\n return f\"{value}\\u03a9\"\n if key.endswith(\"_pf\"):\n return f\"{value}pF\"\n if key.endswith(\"_ppm_c\"):\n return f\"{value}ppm/\\u00b0C\"\n if key.endswith(\"_vus\"):\n return f\"{value}V/\\u00b5s\"\n if key.endswith(\"_ns\"):\n return f\"{value}ns\"\n return str(value)\n\n\ndef build_spec_summary(extractions: dict) -> list[dict]:\n \"\"\"Build key spec summary table for extracted components.\n\n Returns list of spec entries:\n {\n 'mpn': 'TPS54360',\n 'manufacturer': 'Texas Instruments',\n 'category': 'switching_regulator',\n 'key_specs': {\n 'Vin Range': '3.5V \\u2014 60V',\n 'Vref': '0.8V',\n 'Freq': '600 kHz',\n 'Iq': '150 \\u00b5A',\n 'Tj Max': '150\\u00b0C',\n }\n }\n \"\"\"\n specs: list[dict] = []\n\n for mpn, extraction in sorted(extractions.items()):\n category = extraction.get(\"category\", \"\")\n ec = extraction.get(\"electrical_characteristics\", {}) or {}\n roc = extraction.get(\"recommended_operating_conditions\", {}) or {}\n amr = extraction.get(\"absolute_maximum_ratings\", {}) or {}\n\n section_map = {\"ec\": ec, \"roc\": roc, \"amr\": amr}\n\n spec_keys = _CATEGORY_SPEC_KEYS.get(category, _DEFAULT_SPEC_KEYS)\n\n key_specs: dict[str, str] = {}\n\n # Check for Vin range (combine min/max into one entry)\n vin_min = roc.get(\"vin_min_v\")\n vin_max = roc.get(\"vin_max_v\")\n if vin_min is not None and vin_max is not None:\n key_specs[\"Vin Range\"] = f\"{vin_min}V \\u2014 {vin_max}V\"\n # Skip individual vin_min_v/vin_max_v entries\n skip_keys = {\"vin_min_v\", \"vin_max_v\"}\n else:\n skip_keys = set()\n\n # Check for temp range (combine min/max)\n temp_min = roc.get(\"temp_min_c\")\n temp_max = roc.get(\"temp_max_c\")\n if temp_min is not None and temp_max is not None:\n key_specs[\"Temp Range\"] = f\"{temp_min}\\u00b0C \\u2014 {temp_max}\\u00b0C\"\n skip_keys |= {\"temp_min_c\", \"temp_max_c\"}\n\n for json_key, label, section in spec_keys:\n if json_key in skip_keys:\n continue\n source = section_map.get(section, {})\n value = source.get(json_key)\n if value is not None:\n key_specs[label] = _format_spec_value(json_key, value)\n\n specs.append({\n \"mpn\": mpn,\n \"manufacturer\": extraction.get(\"manufacturer\", \"\"),\n \"category\": category,\n \"key_specs\": key_specs,\n })\n\n return specs\n\n\n# ---------------------------------------------------------------------------\n# 5. format_comparison_markdown\n# ---------------------------------------------------------------------------\n\n_STATUS_ICONS = {\n \"ok\": \"\\u2705\",\n \"warning\": \"\\u26a0\\ufe0f\",\n \"mismatch\": \"\\u274c\",\n \"missing\": \"\\u2753\",\n \"nc_ok\": \"\\u2705\",\n}\n\n\ndef format_comparison_markdown(comparisons: list[dict]) -> str:\n \"\"\"Format comparison results as a markdown table for embedding in reports.\"\"\"\n if not comparisons:\n return \"*No datasheet comparisons available.*\"\n\n lines = [\n \"| Ref | MPN | Parameter | Recommended | Actual | Status | Note |\",\n \"|-----|-----|-----------|-------------|--------|--------|------|\",\n ]\n\n for c in comparisons:\n icon = _STATUS_ICONS.get(c[\"status\"], \"\")\n lines.append(\n f\"| {c['ref']} \"\n f\"| {c['mpn']} \"\n f\"| {c['parameter']} \"\n f\"| {c['recommended']} \"\n f\"| {c['actual']} \"\n f\"| {icon} {c['status']} \"\n f\"| {c['note']} |\"\n )\n\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# 6. format_pin_audit_markdown\n# ---------------------------------------------------------------------------\n\ndef format_pin_audit_markdown(audits: list[dict]) -> str:\n \"\"\"Format pin audit results as a markdown table.\"\"\"\n if not audits:\n return \"*No pin audit data available.*\"\n\n lines = [\n \"| Ref | MPN | Pin | Name | Required | Connected To | Status |\",\n \"|-----|-----|-----|------|----------|--------------|--------|\",\n ]\n\n for a in audits:\n icon = _STATUS_ICONS.get(a[\"status\"], \"\")\n lines.append(\n f\"| {a['ref']} \"\n f\"| {a['mpn']} \"\n f\"| {a['pin_number']} \"\n f\"| {a['pin_name']} \"\n f\"| {a['required']} \"\n f\"| {a['connected_to']} \"\n f\"| {icon} {a['status']} |\"\n )\n\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# 7. format_spec_summary_markdown\n# ---------------------------------------------------------------------------\n\ndef format_spec_summary_markdown(specs: list[dict]) -> str:\n \"\"\"Format spec summary as a markdown table.\"\"\"\n if not specs:\n return \"*No datasheet extractions available.*\"\n\n # Collect all unique spec labels across all entries\n all_labels: list[str] = []\n seen: set[str] = set()\n for s in specs:\n for label in s.get(\"key_specs\", {}):\n if label not in seen:\n all_labels.append(label)\n seen.add(label)\n\n headers = [\"MPN\", \"Manufacturer\", \"Category\"] + all_labels\n sep = \"|\" + \"|\".join(\"---\" for _ in headers) + \"|\"\n header_line = \"| \" + \" | \".join(headers) + \" |\"\n\n lines = [header_line, sep]\n for s in specs:\n row = [\n s.get(\"mpn\", \"\"),\n s.get(\"manufacturer\", \"\"),\n s.get(\"category\", \"\").replace(\"_\", \" \"),\n ]\n for label in all_labels:\n row.append(s.get(\"key_specs\", {}).get(label, \"\\u2014\"))\n lines.append(\"| \" + \" | \".join(row) + \" |\")\n\n return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n# CLI\n# ---------------------------------------------------------------------------\n\ndef main():\n parser = argparse.ArgumentParser(\n description=\"Datasheet integration for kidoc reports\")\n parser.add_argument(\"--project-dir\", required=True,\n help=\"KiCad project directory\")\n parser.add_argument(\"--analysis\", required=True,\n help=\"Path to schematic analysis JSON\")\n parser.add_argument(\"--output\", default=None,\n help=\"Output markdown file (optional)\")\n args = parser.parse_args()\n\n # Load analysis\n with open(args.analysis) as f:\n analysis = json.load(f)\n\n # Load extractions\n extractions = load_project_extractions(args.project_dir, analysis)\n print(f\"Found {len(extractions)} datasheet extraction(s)\")\n\n # Build all sections\n comparisons = build_component_comparison(analysis, extractions)\n pin_audits = build_pin_audit(analysis, extractions)\n spec_summary = build_spec_summary(extractions)\n\n # Format output\n sections = []\n sections.append(\"## Datasheet Spec Summary\\n\")\n sections.append(format_spec_summary_markdown(spec_summary))\n sections.append(\"\")\n\n if comparisons:\n sections.append(\"## Component vs Datasheet Comparison\\n\")\n sections.append(format_comparison_markdown(comparisons))\n sections.append(\"\")\n\n if pin_audits:\n sections.append(\"## Pin Audit\\n\")\n sections.append(format_pin_audit_markdown(pin_audits))\n sections.append(\"\")\n\n # Summary stats\n total_pins = len(pin_audits)\n ok_pins = sum(1 for a in pin_audits if a[\"status\"] in (\"ok\", \"nc_ok\"))\n warn_pins = sum(1 for a in pin_audits if a[\"status\"] == \"warning\")\n missing_pins = sum(1 for a in pin_audits if a[\"status\"] == \"missing\")\n if total_pins > 0:\n sections.append(f\"**Pin audit:** {ok_pins}/{total_pins} OK, \"\n f\"{warn_pins} warnings, {missing_pins} missing\")\n total_comp = len(comparisons)\n ok_comp = sum(1 for c in comparisons if c[\"status\"] == \"ok\")\n warn_comp = sum(1 for c in comparisons if c[\"status\"] == \"warning\")\n mismatch_comp = sum(1 for c in comparisons if c[\"status\"] == \"mismatch\")\n missing_comp = sum(1 for c in comparisons if c[\"status\"] == \"missing\")\n if total_comp > 0:\n sections.append(f\"**Component comparison:** {ok_comp}/{total_comp} OK, \"\n f\"{warn_comp} warnings, {mismatch_comp} mismatches, \"\n f\"{missing_comp} missing\")\n\n output_text = \"\\n\".join(sections)\n\n if args.output:\n Path(args.output).parent.mkdir(parents=True, exist_ok=True)\n with open(args.output, \"w\") as f:\n f.write(output_text)\n print(f\"Written to {args.output}\")\n else:\n print(output_text)\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":37604,"content_sha256":"fa403fe6f01b47f3c7705c0d50a788eea7e305d1dcca3437dbfefe71d7d4baae"},{"filename":"scripts/kidoc_diagrams.py","content":"#!/usr/bin/env python3\n\"\"\"Figure generator CLI for engineering documentation.\n\nGenerates all registered figures (power tree, bus topology, architecture,\npinouts, thermal/EMC/SPICE/Monte Carlo charts) from analysis JSON.\n\nShould be run from the reports venv (``reports/.venv/``) so that\nmatplotlib-based generators can render. SVG-only generators work\nwithout the venv but chart generators will be skipped.\n\nUsage:\n python3 kidoc_diagrams.py --analysis schematic.json --output reports/figures/\n python3 kidoc_diagrams.py --analysis schematic.json --output figures/ --config .kicad-happy.json\n python3 kidoc_diagrams.py --analysis schematic.json --output figures/ --force\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\n\n# Ensure this script's directory is on sys.path so figures/ is importable\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n\nfrom figures import run_all, FigureTheme # noqa: E402\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Generate figures from analysis JSON')\n parser.add_argument('--analysis', '-a', required=True,\n help='Path to schematic analysis JSON')\n parser.add_argument('--output', '-o', required=True,\n help='Output directory for figures')\n parser.add_argument('--config', default=None,\n help='Path to .kicad-happy.json config '\n '(for branding/theme)')\n parser.add_argument('--emc', default=None,\n help='Path to EMC analysis JSON (enables emc_severity chart)')\n parser.add_argument('--thermal', default=None,\n help='Path to thermal analysis JSON (enables thermal_margin chart)')\n parser.add_argument('--spice', default=None,\n help='Path to SPICE results JSON (enables spice/monte_carlo charts)')\n parser.add_argument('--force', action='store_true',\n help='Force regeneration (ignore cache)')\n args = parser.parse_args()\n\n with open(args.analysis) as f:\n analysis = json.load(f)\n\n # Merge supplemental analysis data so matplotlib generators can find it.\n # Keys don't collide: schematic has components/nets/findings,\n # thermal has thermal_assessments, EMC has emc_findings, SPICE has simulation_results.\n for path in (args.emc, args.thermal, args.spice):\n if path:\n with open(path) as f:\n analysis.update(json.load(f))\n\n config = {}\n if args.config:\n with open(args.config) as f:\n config = json.load(f)\n\n generated = run_all(analysis, config, args.output, force=args.force)\n\n if not generated:\n print(\"No figures generated (no applicable data found)\",\n file=sys.stderr)\n else:\n for p in generated:\n print(p, file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2929,"content_sha256":"769874d9d6dddc36b57d886d94308bdf1a945fe62be6b214ae88b1f817fb9daf"},{"filename":"scripts/kidoc_docx.py","content":"#!/usr/bin/env python3\n\"\"\"DOCX generation from kidoc markdown scaffolds.\n\nConverts markdown to DOCX using python-docx. SVGs are rasterized to\nPNG via svglib + rl-renderPM before embedding. Runs inside reports/.venv/.\n\nUsage (called by kidoc_generate.py, not directly):\n python3 kidoc_docx.py --input reports/HDD.md --output reports/output/HDD.docx\n --config '{\"project\": {\"name\": \"...\"}}'\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\n\nfrom docx import Document\nfrom docx.shared import Inches, Pt, Cm, RGBColor\nfrom docx.enum.text import WD_ALIGN_PARAGRAPH\nfrom docx.enum.section import WD_ORIENT\n\n# Add kidoc scripts to path for sibling imports\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom kidoc_md_parser import parse_markdown\nfrom kidoc_raster import svg_to_png as _svg_to_png, has_svg_render, get_dpi\n\n\n# ======================================================================\n# Inline formatting\n# ======================================================================\n\ndef _add_runs_to_paragraph(para, runs: list[dict]) -> None:\n \"\"\"Add formatted runs to a python-docx paragraph.\"\"\"\n for r in runs:\n run = para.add_run(r['text'])\n if r.get('bold'):\n run.bold = True\n if r.get('italic'):\n run.italic = True\n if r.get('code'):\n run.font.name = 'Courier New'\n run.font.size = Pt(8)\n run.font.color.rgb = RGBColor(0xC0, 0x40, 0x00)\n\n\n# ======================================================================\n# Element conversion\n# ======================================================================\n\ndef _add_element(doc: Document, elem: dict, base_dir: str,\n dpi: int, temp_files: list) -> None:\n \"\"\"Add a parsed markdown element to the DOCX document.\"\"\"\n etype = elem['type']\n\n if etype == 'heading':\n level = min(elem['level'], 9)\n # python-docx heading levels: 0 = Title, 1-9 = Heading 1-9\n doc_level = 0 if level == 1 else level - 1\n doc.add_heading(elem['text'], level=doc_level)\n\n elif etype == 'paragraph':\n para = doc.add_paragraph()\n _add_runs_to_paragraph(para, elem['runs'])\n\n elif etype == 'image':\n _add_image(doc, elem, base_dir, dpi, temp_files)\n\n elif etype == 'table':\n _add_table(doc, elem)\n\n elif etype == 'code_block':\n para = doc.add_paragraph(style='No Spacing')\n run = para.add_run(elem['code'])\n run.font.name = 'Courier New'\n run.font.size = Pt(7.5)\n\n elif etype == 'hr':\n doc.add_paragraph('_' * 50)\n\n elif etype == 'bullet_list':\n for item_runs in elem['items']:\n para = doc.add_paragraph(style='List Bullet')\n _add_runs_to_paragraph(para, item_runs)\n\n elif etype == 'numbered_list':\n for item_runs in elem['items']:\n para = doc.add_paragraph(style='List Number')\n _add_runs_to_paragraph(para, item_runs)\n\n elif etype == 'blockquote':\n para = doc.add_paragraph(style='Quote')\n _add_runs_to_paragraph(para, elem['runs'])\n\n\ndef _add_image(doc: Document, elem: dict, base_dir: str,\n dpi: int, temp_files: list) -> None:\n \"\"\"Add an image — SVGs are rasterized to PNG first.\"\"\"\n path = elem['path']\n if not os.path.isabs(path):\n path = os.path.join(base_dir, path)\n\n if not os.path.isfile(path):\n para = doc.add_paragraph()\n para.add_run(f'[Image not found: {elem[\"path\"]}]').italic = True\n return\n\n img_path = path\n if path.lower().endswith('.svg'):\n png_path = _svg_to_png(path, dpi=dpi)\n if png_path:\n img_path = png_path\n temp_files.append(png_path)\n else:\n para = doc.add_paragraph()\n para.add_run(f'[SVG rendering unavailable: {elem[\"path\"]}]').italic = True\n return\n\n try:\n doc.add_picture(img_path, width=Inches(6.0))\n # Center the image\n last_para = doc.paragraphs[-1]\n last_para.alignment = WD_ALIGN_PARAGRAPH.CENTER\n except Exception as e:\n para = doc.add_paragraph()\n para.add_run(f'[Failed to embed image: {elem[\"path\"]}]').italic = True\n\n # Caption\n if elem.get('alt'):\n cap = doc.add_paragraph()\n cap.alignment = WD_ALIGN_PARAGRAPH.CENTER\n run = cap.add_run(elem['alt'])\n run.italic = True\n run.font.size = Pt(8)\n run.font.color.rgb = RGBColor(0x60, 0x60, 0x60)\n\n\ndef _add_table(doc: Document, elem: dict) -> None:\n \"\"\"Add a table to the DOCX with explicit borders.\"\"\"\n from docx.oxml.ns import qn\n from docx.oxml import OxmlElement\n\n headers = elem['headers']\n rows = elem['rows']\n n_cols = len(headers)\n\n table = doc.add_table(rows=1 + len(rows), cols=n_cols)\n\n # Set 'Table Grid' style (provides borders in most templates)\n try:\n table.style = 'Table Grid'\n except KeyError:\n pass # Style not available in this template\n\n # Explicit border XML — ensures borders render even without the style\n tbl = table._tbl\n tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')\n borders = OxmlElement('w:tblBorders')\n for edge in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):\n el = OxmlElement(f'w:{edge}')\n el.set(qn('w:val'), 'single')\n el.set(qn('w:sz'), '4')\n el.set(qn('w:space'), '0')\n el.set(qn('w:color'), 'C0C0C0')\n borders.append(el)\n tblPr.append(borders)\n\n # Header row with shading\n for i, h in enumerate(headers):\n cell = table.rows[0].cells[i]\n cell.text = h\n for para in cell.paragraphs:\n for run in para.runs:\n run.bold = True\n run.font.size = Pt(8)\n # Header cell shading\n shading = OxmlElement('w:shd')\n shading.set(qn('w:fill'), 'E8E8F0')\n shading.set(qn('w:val'), 'clear')\n cell._tc.get_or_add_tcPr().append(shading)\n\n # Data rows\n for r_idx, row in enumerate(rows):\n for c_idx in range(n_cols):\n cell_text = row[c_idx] if c_idx \u003c len(row) else ''\n cell = table.rows[r_idx + 1].cells[c_idx]\n cell.text = cell_text\n for para in cell.paragraphs:\n for run in para.runs:\n run.font.size = Pt(8)\n\n\n# ======================================================================\n# Header / footer\n# ======================================================================\n\ndef _add_header_footer(doc: Document, config: dict) -> None:\n \"\"\"Add header and footer to all sections.\"\"\"\n project = config.get('project', {})\n branding = config.get('reports', {}).get('branding', {})\n header_left = branding.get('header_left', project.get('company', ''))\n classification = config.get('reports', {}).get('classification', '')\n\n # Resolve placeholders\n for key, val in project.items():\n header_left = header_left.replace(f'{{{key}}}', str(val))\n\n for section in doc.sections:\n # Header\n header = section.header\n header.is_linked_to_previous = False\n if header_left:\n para = header.paragraphs[0] if header.paragraphs else header.add_paragraph()\n para.text = header_left\n para.style.font.size = Pt(8)\n para.style.font.color.rgb = RGBColor(0x80, 0x80, 0x80)\n\n # Footer\n footer = section.footer\n footer.is_linked_to_previous = False\n para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()\n para.alignment = WD_ALIGN_PARAGRAPH.CENTER\n run = para.add_run('Page ')\n run.font.size = Pt(8)\n run.font.color.rgb = RGBColor(0x80, 0x80, 0x80)\n\n\n# ======================================================================\n# Main generation\n# ======================================================================\n\ndef generate_docx(markdown_path: str, output_path: str, config: dict) -> str:\n \"\"\"Convert markdown to DOCX. Returns the output path.\"\"\"\n with open(markdown_path, 'r', encoding='utf-8') as f:\n md_text = f.read()\n\n elements = parse_markdown(md_text)\n base_dir = os.path.dirname(os.path.abspath(markdown_path))\n\n # DPI for SVG rasterization\n dpi = config.get('reports', {}).get('rendering', {}).get('schematic_dpi', 300)\n\n # Create document (from template if available)\n template = config.get('reports', {}).get('branding', {}).get('cover_template')\n if template and os.path.isfile(template):\n doc = Document(template)\n else:\n doc = Document()\n\n # Track temp files for cleanup\n temp_files: list[str] = []\n\n try:\n for elem in elements:\n _add_element(doc, elem, base_dir, dpi, temp_files)\n\n _add_header_footer(doc, config)\n\n os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', exist_ok=True)\n doc.save(output_path)\n finally:\n # Clean up temp PNG files\n for tf in temp_files:\n try:\n os.unlink(tf)\n except OSError:\n pass\n\n return output_path\n\n\ndef main():\n parser = argparse.ArgumentParser(description='Generate DOCX from markdown')\n parser.add_argument('--input', '-i', required=True,\n help='Input markdown file')\n parser.add_argument('--output', '-o', required=True,\n help='Output DOCX file')\n parser.add_argument('--config', '-c', default='{}',\n help='JSON config string or path to config file')\n args = parser.parse_args()\n\n if os.path.isfile(args.config):\n with open(args.config) as f:\n config = json.load(f)\n else:\n config = json.loads(args.config)\n\n output = generate_docx(args.input, args.output, config)\n print(output, file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9950,"content_sha256":"10f12d682815f6972f1532f0f0b83a225e148f2ce4cbe6d5cfcf165ab5122196"},{"filename":"scripts/kidoc_generate.py","content":"#!/usr/bin/env python3\n\"\"\"Orchestrator for kidoc document generation.\n\nManages the full pipeline: analysis → render → scaffold → PDF/DOCX.\nRuns analysis and rendering with system Python (zero-dep), then\ndispatches PDF/DOCX generation to the project-local venv.\n\nUsage:\n python3 kidoc_generate.py --project-dir . --format pdf\n python3 kidoc_generate.py --project-dir . --format docx\n python3 kidoc_generate.py --project-dir . --format all\n python3 kidoc_generate.py --project-dir . --doc HDD.md --format pdf\n\nZero external dependencies — Python stdlib only (dispatches to venv for PDF/DOCX).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom kidoc_venv import ensure_venv, venv_python\n\n_kicad_scripts = os.path.join(os.path.dirname(os.path.abspath(__file__)),\n '..', '..', 'kicad', 'scripts')\nif os.path.isdir(_kicad_scripts):\n sys.path.insert(0, os.path.abspath(_kicad_scripts))\n\ntry:\n from project_config import load_config, load_config_from_path\nexcept ImportError:\n def load_config(search_dir):\n return {'version': 1, 'project': {}, 'suppressions': []}\n def load_config_from_path(path):\n return {'version': 1, 'project': {}, 'suppressions': []}\n\n\nSCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))\n\n# Map markdown stem → human-readable document type name\n_DOC_TYPE_NAMES = {\n 'hdd': 'Hardware Design Description',\n 'ce_technical_file': 'CE Technical File',\n 'design_review': 'Design Review',\n 'icd': 'Interface Control Document',\n 'manufacturing': 'Manufacturing Transfer Package',\n}\n\n\ndef _build_filename(stem: str, project_name: str, revision: str) -> str:\n \"\"\"Build a human-readable filename from project info.\n\n Examples:\n \"SacMap Rev2 - Hardware Design Description Rev 2.0\"\n \"Widget Board - Design Review Rev 1.1\"\n \"HDD\" (fallback if no project name)\n \"\"\"\n # Try to match stem to a known doc type\n doc_type_name = ''\n stem_lower = stem.lower().replace('-', '_').replace(' ', '_')\n for key, name in _DOC_TYPE_NAMES.items():\n if key in stem_lower or stem_lower in key:\n doc_type_name = name\n break\n if not doc_type_name:\n doc_type_name = stem.replace('_', ' ').replace('-', ' ').title()\n\n parts = []\n if project_name:\n parts.append(project_name)\n parts.append(doc_type_name)\n name = ' - '.join(parts)\n\n if revision:\n name += f' Rev {revision}'\n\n # Sanitize for filesystem\n name = name.replace('/', '-').replace('\\\\', '-').replace(':', '-')\n return name\n\n\ndef _find_markdown_files(project_dir: str) -> list[str]:\n \"\"\"Find markdown scaffolds in the reports/ directory.\"\"\"\n reports_dir = os.path.join(project_dir, 'reports')\n if not os.path.isdir(reports_dir):\n return []\n return sorted(\n os.path.join(reports_dir, f)\n for f in os.listdir(reports_dir)\n if f.endswith('.md') and not f.startswith('.')\n )\n\n\ndef _run_format_generator(format_name: str, python: str, script: str,\n md_path: str, output_path: str,\n config: dict) -> bool:\n \"\"\"Run a document format generator as a subprocess.\n\n Args:\n format_name: Human label for error messages (e.g. \"PDF\").\n python: Python interpreter (system or venv).\n script: Generator script name (e.g. 'kidoc_pdf.py').\n md_path: Input markdown path.\n output_path: Output file path.\n config: Project config dict (serialized as JSON arg).\n\n Returns True on success.\n \"\"\"\n cmd = [\n python,\n os.path.join(SCRIPTS_DIR, script),\n '--input', md_path,\n '--output', output_path,\n '--config', json.dumps(config),\n ]\n result = subprocess.run(cmd, capture_output=True, text=True)\n if result.returncode != 0:\n print(f\"{format_name} generation failed: {result.stderr}\",\n file=sys.stderr)\n return False\n return True\n\n\n\n\ndef generate_documents(project_dir: str, formats: list[str],\n doc_name: str | None = None,\n config: dict | None = None) -> list[str]:\n \"\"\"Generate PDF/DOCX from markdown scaffolds.\n\n Returns list of output file paths.\n \"\"\"\n if config is None:\n config = load_config(project_dir)\n\n # Find markdown files\n if doc_name:\n md_path = doc_name\n if not os.path.isabs(md_path):\n md_path = os.path.join(project_dir, 'reports', md_path)\n md_files = [md_path] if os.path.isfile(md_path) else []\n else:\n md_files = _find_markdown_files(project_dir)\n\n if not md_files:\n print(\"No markdown files found in reports/. Run kidoc_scaffold.py first.\",\n file=sys.stderr)\n return []\n\n # Ensure venv for PDF/DOCX/ODT (not needed for HTML)\n needs_venv = any(f in formats for f in ('pdf', 'docx', 'odt', 'all'))\n venv_py = None\n if needs_venv:\n print(\"Checking report generation environment...\", file=sys.stderr)\n venv_py = ensure_venv(project_dir)\n\n output_dir = os.path.join(project_dir, 'reports', 'output')\n os.makedirs(output_dir, exist_ok=True)\n\n outputs = []\n for md_path in md_files:\n stem = Path(md_path).stem\n project = config.get('project', {})\n rev = project.get('revision', '')\n proj_name = project.get('name', '')\n\n # Build human-readable filename a manager can understand\n # e.g. \"SacMap Rev2 - Hardware Design Description Rev 2.0.pdf\"\n base_name = _build_filename(stem, proj_name, rev)\n\n # Format → (label, script, needs_venv)\n _FORMAT_SCRIPTS = {\n 'html': ('HTML', 'kidoc_html.py', False),\n 'pdf': ('PDF', 'kidoc_pdf.py', True),\n 'docx': ('DOCX', 'kidoc_docx.py', True),\n 'odt': ('ODT', 'kidoc_odt.py', True),\n }\n\n for fmt, (label, script, needs) in _FORMAT_SCRIPTS.items():\n if fmt not in formats and 'all' not in formats:\n continue\n out_path = os.path.join(output_dir, f\"{base_name}.{fmt}\")\n print(f\"Generating {label}: {out_path}\", file=sys.stderr)\n py = sys.executable\n if needs:\n if venv_py is None:\n venv_py = ensure_venv(project_dir)\n py = venv_py\n if _run_format_generator(label, py, script,\n md_path, out_path, config):\n outputs.append(out_path)\n print(f\" -> {out_path}\", file=sys.stderr)\n\n return outputs\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Generate PDF/DOCX from kidoc markdown scaffolds')\n parser.add_argument('--project-dir', '-p', default='.',\n help='Path to KiCad project directory')\n parser.add_argument('--format', '-f', default='pdf',\n choices=['pdf', 'html', 'docx', 'odt', 'all'],\n help='Output format (default: pdf)')\n parser.add_argument('--doc', default=None,\n help='Specific markdown file to process')\n parser.add_argument('--config', default=None,\n help='Path to .kicad-happy.json config')\n parser.add_argument('--spec', default=None,\n help='Path to document spec JSON')\n args = parser.parse_args()\n\n if args.config:\n config = load_config_from_path(args.config)\n else:\n config = load_config(args.project_dir)\n\n # When --spec is provided, use its title as fallback project name\n if args.spec:\n sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n from kidoc_spec import load_spec\n spec = load_spec(args.spec)\n if not config.get('project', {}).get('name'):\n config.setdefault('project', {})['name'] = spec.get('title', '')\n\n formats = [args.format] if args.format != 'all' else ['html', 'pdf', 'docx', 'odt']\n\n outputs = generate_documents(\n project_dir=args.project_dir,\n formats=formats,\n doc_name=args.doc,\n config=config,\n )\n\n if outputs:\n print(f\"\\nGenerated {len(outputs)} document(s):\", file=sys.stderr)\n for o in outputs:\n print(f\" {o}\", file=sys.stderr)\n else:\n print(\"No documents generated.\", file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8561,"content_sha256":"811885d14d09fc2060b650d031a08a006dc034956f334ca53da003aba29da332"},{"filename":"scripts/kidoc_html.py","content":"#!/usr/bin/env python3\n\"\"\"HTML generation from kidoc markdown scaffolds.\n\nConverts markdown to a self-contained HTML file with embedded CSS.\nSVG images are inlined directly (no rasterization needed). This is the\nlightest output format — zero dependencies beyond Python stdlib.\n\nUsage (called by kidoc_generate.py, or directly):\n python3 kidoc_html.py --input reports/HDD.md --output reports/output/HDD.html\n --config '{\"project\": {\"name\": \"...\"}}'\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport base64\nimport json\nimport os\nimport sys\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom kidoc_md_parser import parse_markdown\n\n\n# ======================================================================\n# CSS\n# ======================================================================\n\nCSS = \"\"\"\nbody {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,\n 'Helvetica Neue', Arial, sans-serif;\n max-width: 900px;\n margin: 40px auto;\n padding: 0 20px;\n color: #1a1a1a;\n line-height: 1.6;\n font-size: 14px;\n}\nh1 { font-size: 24px; border-bottom: 2px solid #2060c0; padding-bottom: 8px; }\nh2 { font-size: 20px; border-bottom: 1px solid #e0e0e0; padding-bottom: 6px; margin-top: 32px; }\nh3 { font-size: 16px; margin-top: 24px; }\nh4, h5, h6 { font-size: 14px; margin-top: 16px; }\ntable {\n border-collapse: collapse;\n width: 100%;\n margin: 16px 0;\n font-size: 13px;\n}\nth, td {\n border: 1px solid #d0d0d0;\n padding: 6px 10px;\n text-align: left;\n}\nth { background: #e8e8f0; font-weight: 600; }\ntr:nth-child(even) { background: #fafafa; }\nimg, svg { max-width: 100%; height: auto; margin: 16px 0; display: block; }\ncode {\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n background: #f5f5f5;\n padding: 1px 4px;\n border-radius: 3px;\n font-size: 13px;\n color: #c04000;\n}\npre {\n background: #f5f5f5;\n padding: 12px 16px;\n border-radius: 4px;\n overflow-x: auto;\n border: 1px solid #e0e0e0;\n}\npre code { background: none; padding: 0; color: #303030; }\nblockquote {\n border-left: 3px solid #2060c0;\n margin: 16px 0;\n padding: 8px 16px;\n color: #606060;\n font-style: italic;\n}\n.caption { text-align: center; font-size: 12px; color: #606060; font-style: italic; }\n.header { color: #808080; font-size: 11px; margin-bottom: 24px; }\n.footer { color: #808080; font-size: 11px; margin-top: 40px; border-top: 1px solid #e0e0e0; padding-top: 8px; }\nhr { border: none; border-top: 1px solid #e0e0e0; margin: 24px 0; }\nul, ol { padding-left: 24px; }\n.toc { background: #f8f8fc; border: 1px solid #e0e0e8; border-radius: 6px;\n padding: 12px 20px; margin: 16px 0 24px; }\n.toc h3 { margin: 0 0 8px; font-size: 14px; color: #404060; }\n.toc ul { list-style: none; padding: 0; margin: 0; }\n.toc li { padding: 2px 0; }\n.toc a { color: #2060c0; text-decoration: none; font-size: 13px; }\n.toc a:hover { text-decoration: underline; }\n@media print {\n .toc { break-after: page; }\n h2 { break-before: page; }\n table { break-inside: avoid; }\n .header, .footer { position: fixed; }\n .header { top: 0; }\n .footer { bottom: 0; }\n}\n\"\"\"\n\n\n# ======================================================================\n# Element to HTML conversion\n# ======================================================================\n\ndef _escape(text: str) -> str:\n \"\"\"Escape HTML special characters.\"\"\"\n return (text.replace('&', '&').replace('\u003c', '<')\n .replace('>', '>').replace('\"', '"'))\n\n\ndef _runs_to_html(runs: list[dict]) -> str:\n \"\"\"Convert inline runs to HTML.\"\"\"\n parts = []\n for r in runs:\n text = _escape(r['text'])\n if r.get('code'):\n parts.append(f'\u003ccode>{text}\u003c/code>')\n elif r.get('bold') and r.get('italic'):\n parts.append(f'\u003cstrong>\u003cem>{text}\u003c/em>\u003c/strong>')\n elif r.get('bold'):\n parts.append(f'\u003cstrong>{text}\u003c/strong>')\n elif r.get('italic'):\n parts.append(f'\u003cem>{text}\u003c/em>')\n elif r.get('link'):\n parts.append(f'\u003ca href=\"{_escape(r[\"link\"])}\">{text}\u003c/a>')\n else:\n parts.append(text)\n return ''.join(parts)\n\n\ndef _element_to_html(elem: dict, base_dir: str) -> str:\n \"\"\"Convert a parsed markdown element to HTML.\"\"\"\n etype = elem['type']\n\n if etype == 'heading':\n level = elem['level']\n anchor = elem.get('_anchor', '')\n anchor_attr = f' id=\"{anchor}\"' if anchor else ''\n return f'\u003ch{level}{anchor_attr}>{_escape(elem[\"text\"])}\u003c/h{level}>'\n\n elif etype == 'paragraph':\n return f'\u003cp>{_runs_to_html(elem[\"runs\"])}\u003c/p>'\n\n elif etype == 'image':\n return _build_image_html(elem, base_dir)\n\n elif etype == 'table':\n return _build_table_html(elem)\n\n elif etype == 'code_block':\n lang = f' class=\"language-{elem[\"language\"]}\"' if elem.get('language') else ''\n return f'\u003cpre>\u003ccode{lang}>{_escape(elem[\"code\"])}\u003c/code>\u003c/pre>'\n\n elif etype == 'hr':\n return '\u003chr>'\n\n elif etype == 'bullet_list':\n items = ''.join(f'\u003cli>{_runs_to_html(runs)}\u003c/li>' for runs in elem['items'])\n return f'\u003cul>{items}\u003c/ul>'\n\n elif etype == 'numbered_list':\n items = ''.join(f'\u003cli>{_runs_to_html(runs)}\u003c/li>' for runs in elem['items'])\n return f'\u003col>{items}\u003c/ol>'\n\n elif etype == 'blockquote':\n return f'\u003cblockquote>\u003cp>{_runs_to_html(elem[\"runs\"])}\u003c/p>\u003c/blockquote>'\n\n return ''\n\n\ndef _build_image_html(elem: dict, base_dir: str) -> str:\n \"\"\"Build image HTML — SVGs inlined, rasters base64-encoded.\"\"\"\n path = elem['path']\n if not os.path.isabs(path):\n path = os.path.join(base_dir, path)\n\n if not os.path.isfile(path):\n return f'\u003cp class=\"caption\">\u003cem>[Image not found: {_escape(elem[\"path\"])}]\u003c/em>\u003c/p>'\n\n alt = _escape(elem.get('alt', ''))\n html = ''\n\n if path.lower().endswith('.svg'):\n # Inline SVG directly — vector quality, no rasterization\n with open(path, 'r', encoding='utf-8') as f:\n svg_content = f.read()\n # Strip XML declaration if present\n if svg_content.startswith('\u003c?xml'):\n svg_content = svg_content[svg_content.index('?>') + 2:].strip()\n html = f'\u003cdiv>{svg_content}\u003c/div>'\n else:\n # Base64-encode raster images\n ext = os.path.splitext(path)[1].lower()\n mime = {'png': 'image/png', 'jpg': 'image/jpeg',\n 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(ext.lstrip('.'), 'image/png')\n with open(path, 'rb') as f:\n b64 = base64.b64encode(f.read()).decode('ascii')\n html = f'\u003cimg src=\"data:{mime};base64,{b64}\" alt=\"{alt}\">'\n\n if alt:\n html += f'\\n\u003cp class=\"caption\">{alt}\u003c/p>'\n\n return html\n\n\ndef _build_table_html(elem: dict) -> str:\n \"\"\"Build HTML table.\"\"\"\n headers = elem['headers']\n rows = elem['rows']\n alignments = elem.get('alignments', ['left'] * len(headers))\n\n html = '\u003ctable>\\n\u003cthead>\u003ctr>'\n for i, h in enumerate(headers):\n align = f' style=\"text-align:{alignments[i]}\"' if i \u003c len(alignments) else ''\n html += f'\u003cth{align}>{_escape(h)}\u003c/th>'\n html += '\u003c/tr>\u003c/thead>\\n\u003ctbody>'\n\n for row in rows:\n html += '\u003ctr>'\n for i in range(len(headers)):\n cell = row[i] if i \u003c len(row) else ''\n align = f' style=\"text-align:{alignments[i]}\"' if i \u003c len(alignments) else ''\n html += f'\u003ctd{align}>{_escape(cell)}\u003c/td>'\n html += '\u003c/tr>'\n\n html += '\u003c/tbody>\\n\u003c/table>'\n return html\n\n\n# ======================================================================\n# Main generation\n# ======================================================================\n\ndef generate_html(markdown_path: str, output_path: str, config: dict) -> str:\n \"\"\"Convert markdown to self-contained HTML. Returns the output path.\"\"\"\n with open(markdown_path, 'r', encoding='utf-8') as f:\n md_text = f.read()\n\n elements = parse_markdown(md_text)\n base_dir = os.path.dirname(os.path.abspath(markdown_path))\n\n project = config.get('project', {})\n branding = config.get('reports', {}).get('branding', {})\n classification = config.get('reports', {}).get('classification', '')\n\n # Build HTML\n body_parts = []\n\n # Header\n header_left = branding.get('header_left', project.get('company', ''))\n header_right = branding.get('header_right', '')\n for key, val in project.items():\n header_left = header_left.replace('{' + key + '}', str(val))\n header_right = header_right.replace('{' + key + '}', str(val))\n if header_left or header_right:\n body_parts.append(\n f'\u003cdiv class=\"header\">{_escape(header_left)}'\n f'\u003cspan style=\"float:right\">{_escape(header_right)}\u003c/span>\u003c/div>')\n\n # Build table of contents from headings\n toc_entries = []\n heading_id = 0\n for elem in elements:\n if elem['type'] == 'heading' and elem['level'] in (1, 2, 3):\n heading_id += 1\n anchor = f'section-{heading_id}'\n toc_entries.append((elem['level'], elem['text'], anchor))\n elem['_anchor'] = anchor # stash for rendering\n\n if len(toc_entries) > 2:\n toc_html = '\u003cnav class=\"toc\">\u003ch3>Contents\u003c/h3>\u003cul>'\n for level, text, anchor in toc_entries:\n indent = 'style=\"margin-left:16px\"' if level == 3 else ''\n toc_html += f'\u003cli {indent}>\u003ca href=\"#{anchor}\">{_escape(text)}\u003c/a>\u003c/li>'\n toc_html += '\u003c/ul>\u003c/nav>'\n body_parts.append(toc_html)\n\n # Content\n for elem in elements:\n html = _element_to_html(elem, base_dir)\n if html:\n body_parts.append(html)\n\n # Footer\n if classification:\n body_parts.append(f'\u003cdiv class=\"footer\">{_escape(classification)}\u003c/div>')\n\n title = project.get('name', 'Engineering Document')\n page = f\"\"\"\u003c!DOCTYPE html>\n\u003chtml lang=\"en\">\n\u003chead>\n\u003cmeta charset=\"UTF-8\">\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\u003ctitle>{_escape(title)}\u003c/title>\n\u003cstyle>{CSS}\u003c/style>\n\u003c/head>\n\u003cbody>\n{chr(10).join(body_parts)}\n\u003c/body>\n\u003c/html>\"\"\"\n\n os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', exist_ok=True)\n with open(output_path, 'w', encoding='utf-8') as f:\n f.write(page)\n\n return output_path\n\n\ndef main():\n parser = argparse.ArgumentParser(description='Generate HTML from markdown')\n parser.add_argument('--input', '-i', required=True,\n help='Input markdown file')\n parser.add_argument('--output', '-o', required=True,\n help='Output HTML file')\n parser.add_argument('--config', '-c', default='{}',\n help='JSON config string or path to config file')\n args = parser.parse_args()\n\n if os.path.isfile(args.config):\n with open(args.config) as f:\n config = json.load(f)\n else:\n config = json.loads(args.config)\n\n output = generate_html(args.input, args.output, config)\n print(output, file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":11139,"content_sha256":"3befd444fc8b0199fda6ae1c68a4290dad0b17ffc6d3944fb48640b06187d93c"},{"filename":"scripts/kidoc_md_parser.py","content":"\"\"\"Markdown parser for kidoc document generation.\n\nParses the subset of markdown used by kidoc scaffolds into a list of\ntyped document elements. Used by both kidoc_pdf.py and kidoc_docx.py.\n\nSupports: headings, paragraphs (with bold/italic/code), tables, images,\ncode blocks, horizontal rules, bullet lists, numbered lists, blockquotes.\nHTML comments are skipped silently.\n\nZero external dependencies — Python stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom typing import Any\n\n\n# ======================================================================\n# Element types\n# ======================================================================\n\ndef heading(level: int, text: str) -> dict:\n return {'type': 'heading', 'level': level, 'text': text}\n\ndef paragraph(runs: list[dict]) -> dict:\n return {'type': 'paragraph', 'runs': runs}\n\ndef table(headers: list[str], rows: list[list[str]],\n alignments: list[str]) -> dict:\n return {'type': 'table', 'headers': headers, 'rows': rows,\n 'alignments': alignments}\n\ndef image(alt: str, path: str) -> dict:\n return {'type': 'image', 'alt': alt, 'path': path}\n\ndef code_block(code: str, language: str = '') -> dict:\n return {'type': 'code_block', 'code': code, 'language': language}\n\ndef horizontal_rule() -> dict:\n return {'type': 'hr'}\n\ndef bullet_list(items: list[list[dict]]) -> dict:\n return {'type': 'bullet_list', 'items': items}\n\ndef numbered_list(items: list[list[dict]]) -> dict:\n return {'type': 'numbered_list', 'items': items}\n\ndef blockquote(runs: list[dict]) -> dict:\n return {'type': 'blockquote', 'runs': runs}\n\n\n# ======================================================================\n# Inline parsing (bold, italic, code, links)\n# ======================================================================\n\n_INLINE_PATTERNS = [\n # Order matters: longer/more specific patterns first\n (re.compile(r'\\*\\*\\*(.+?)\\*\\*\\*'), 'bold_italic'),\n (re.compile(r'\\*\\*(.+?)\\*\\*'), 'bold'),\n (re.compile(r'\\*(.+?)\\*'), 'italic'),\n (re.compile(r'`(.+?)`'), 'code'),\n (re.compile(r'\\[(.+?)\\]\\((.+?)\\)'), 'link'),\n]\n\n\ndef parse_inline(text: str) -> list[dict]:\n \"\"\"Parse inline formatting into a list of runs.\n\n Each run: {'text': str, 'bold': bool, 'italic': bool, 'code': bool, 'link': str|None}\n \"\"\"\n runs: list[dict] = []\n pos = 0\n\n while pos \u003c len(text):\n best_match = None\n best_start = len(text)\n best_pattern = None\n\n for pattern, ptype in _INLINE_PATTERNS:\n m = pattern.search(text, pos)\n if m and m.start() \u003c best_start:\n best_match = m\n best_start = m.start()\n best_pattern = ptype\n\n if best_match is None:\n # No more inline formatting — rest is plain text\n remaining = text[pos:]\n if remaining:\n runs.append(_run(remaining))\n break\n\n # Add plain text before the match\n if best_start > pos:\n runs.append(_run(text[pos:best_start]))\n\n # Add the formatted run\n if best_pattern == 'bold_italic':\n runs.append(_run(best_match.group(1), bold=True, italic=True))\n elif best_pattern == 'bold':\n runs.append(_run(best_match.group(1), bold=True))\n elif best_pattern == 'italic':\n runs.append(_run(best_match.group(1), italic=True))\n elif best_pattern == 'code':\n runs.append(_run(best_match.group(1), code=True))\n elif best_pattern == 'link':\n runs.append(_run(best_match.group(1), link=best_match.group(2)))\n\n pos = best_match.end()\n\n return runs if runs else [_run(text)]\n\n\ndef _run(text: str, bold: bool = False, italic: bool = False,\n code: bool = False, link: str | None = None) -> dict:\n return {'text': text, 'bold': bold, 'italic': italic,\n 'code': code, 'link': link}\n\n\n# ======================================================================\n# Block-level parsing\n# ======================================================================\n\n_HEADING_RE = re.compile(r'^(#{1,6})\\s+(.+)

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_IMAGE_RE = re.compile(r'^!\\[([^\\]]*)\\]\\(([^)]+)\\)\\s*

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_TABLE_SEP_RE = re.compile(r'^\\|[\\s:|-]+\\|\\s*

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_TABLE_ROW_RE = re.compile(r'^\\|(.+)\\|\\s*

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_CODE_FENCE_RE = re.compile(r'^```(\\w*)\\s*

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_HR_RE = re.compile(r'^---+\\s*

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_BULLET_RE = re.compile(r'^[-*+]\\s+(.+)

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_NUMBERED_RE = re.compile(r'^\\d+\\.\\s+(.+)

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_BLOCKQUOTE_RE = re.compile(r'^>\\s*(.*)

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n_COMMENT_RE = re.compile(r'^\u003c!--.*-->

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n\n\ndef parse_markdown(text: str) -> list[dict]:\n \"\"\"Parse markdown text into a list of document elements.\"\"\"\n lines = text.split('\\n')\n elements: list[dict] = []\n i = 0\n n = len(lines)\n\n while i \u003c n:\n line = lines[i]\n stripped = line.strip()\n\n # Skip empty lines\n if not stripped:\n i += 1\n continue\n\n # Skip HTML comments (AUTO markers, NARRATIVE markers)\n if _COMMENT_RE.match(stripped):\n i += 1\n continue\n\n # Code fence\n m = _CODE_FENCE_RE.match(stripped)\n if m:\n lang = m.group(1)\n code_lines = []\n i += 1\n while i \u003c n and not _CODE_FENCE_RE.match(lines[i].strip()):\n code_lines.append(lines[i])\n i += 1\n i += 1 # skip closing fence\n elements.append(code_block('\\n'.join(code_lines), lang))\n continue\n\n # Heading\n m = _HEADING_RE.match(stripped)\n if m:\n level = len(m.group(1))\n elements.append(heading(level, m.group(2)))\n i += 1\n continue\n\n # Horizontal rule\n if _HR_RE.match(stripped):\n elements.append(horizontal_rule())\n i += 1\n continue\n\n # Image (standalone on a line)\n m = _IMAGE_RE.match(stripped)\n if m:\n elements.append(image(m.group(1), m.group(2)))\n i += 1\n continue\n\n # Table (starts with | and next line is separator)\n if _TABLE_ROW_RE.match(stripped) and i + 1 \u003c n and _TABLE_SEP_RE.match(lines[i + 1].strip()):\n headers = _parse_table_row(stripped)\n alignments = _parse_table_alignments(lines[i + 1].strip())\n rows = []\n i += 2 # skip header and separator\n while i \u003c n and _TABLE_ROW_RE.match(lines[i].strip()):\n rows.append(_parse_table_row(lines[i].strip()))\n i += 1\n elements.append(table(headers, rows, alignments))\n continue\n\n # Bullet list\n m = _BULLET_RE.match(stripped)\n if m:\n items = []\n while i \u003c n and _BULLET_RE.match(lines[i].strip()):\n item_m = _BULLET_RE.match(lines[i].strip())\n items.append(parse_inline(item_m.group(1)))\n i += 1\n elements.append(bullet_list(items))\n continue\n\n # Numbered list\n m = _NUMBERED_RE.match(stripped)\n if m:\n items = []\n while i \u003c n and _NUMBERED_RE.match(lines[i].strip()):\n item_m = _NUMBERED_RE.match(lines[i].strip())\n items.append(parse_inline(item_m.group(1)))\n i += 1\n elements.append(numbered_list(items))\n continue\n\n # Blockquote\n m = _BLOCKQUOTE_RE.match(stripped)\n if m:\n quote_lines = []\n while i \u003c n and _BLOCKQUOTE_RE.match(lines[i].strip()):\n qm = _BLOCKQUOTE_RE.match(lines[i].strip())\n quote_lines.append(qm.group(1))\n i += 1\n elements.append(blockquote(parse_inline(' '.join(quote_lines))))\n continue\n\n # Default: paragraph (collect consecutive non-empty, non-special lines)\n para_lines = []\n while i \u003c n:\n l = lines[i].strip()\n if not l:\n break\n if (_HEADING_RE.match(l) or _HR_RE.match(l) or _IMAGE_RE.match(l)\n or _CODE_FENCE_RE.match(l) or _COMMENT_RE.match(l)\n or (_TABLE_ROW_RE.match(l) and i + 1 \u003c n\n and _TABLE_SEP_RE.match(lines[i + 1].strip()))\n or _BULLET_RE.match(l) or _NUMBERED_RE.match(l)):\n break\n para_lines.append(l)\n i += 1\n if para_lines:\n elements.append(paragraph(parse_inline(' '.join(para_lines))))\n\n return elements\n\n\n# ======================================================================\n# Table helpers\n# ======================================================================\n\ndef _parse_table_row(line: str) -> list[str]:\n \"\"\"Parse a markdown table row into cells.\"\"\"\n # Strip leading/trailing pipes and split\n line = line.strip()\n if line.startswith('|'):\n line = line[1:]\n if line.endswith('|'):\n line = line[:-1]\n return [cell.strip() for cell in line.split('|')]\n\n\ndef _parse_table_alignments(sep_line: str) -> list[str]:\n \"\"\"Parse table separator to determine column alignments.\"\"\"\n cells = _parse_table_row(sep_line)\n alignments = []\n for cell in cells:\n cell = cell.strip()\n if cell.startswith(':') and cell.endswith(':'):\n alignments.append('center')\n elif cell.endswith(':'):\n alignments.append('right')\n else:\n alignments.append('left')\n return alignments\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9501,"content_sha256":"05edb7f3e2dddd08cd46d85b418a9802dd74441a3c7c7b77fc535d52d1c73a60"},{"filename":"scripts/kidoc_narrative_augment.py","content":"\"\"\"Narrative augmentation — datasheet, SPICE, and cross-reference builders.\n\nThese functions enrich narrative context with supplemental data sources\nbeyond the primary schematic analysis JSON.\n\nZero external dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\n\n# ======================================================================\n# Datasheet notes\n# ======================================================================\n\ndef build_datasheet_notes(section_type: str, analysis: dict,\n extractions: dict | None) -> str:\n \"\"\"Build datasheet notes relevant to a section.\"\"\"\n if not extractions:\n return ''\n\n parts = []\n\n if section_type == 'power_design':\n regs = [f for f in analysis.get('findings', [])\n if f.get('detector') == 'detect_power_regulators']\n for r in regs:\n value = r.get('value', '')\n ref = r.get('ref', '')\n # Look for extraction by MPN or value\n for key in (r.get('mpn', ''), value):\n if key and key in extractions:\n ext = extractions[key]\n parts.append(f\"{ref} ({value}): {_summarize_extraction(ext)}\")\n\n elif section_type == 'analog_design':\n opamps = [f for f in analysis.get('findings', [])\n if f.get('detector') == 'detect_opamp_circuits']\n for o in opamps:\n value = o.get('value', '')\n ref = o.get('ref', '')\n for key in (o.get('mpn', ''), value):\n if key and key in extractions:\n ext = extractions[key]\n parts.append(f\"{ref} ({value}): {_summarize_extraction(ext)}\")\n\n return '\\n'.join(parts)\n\n\ndef _summarize_extraction(ext: dict) -> str:\n \"\"\"One-line summary of a datasheet extraction.\"\"\"\n parts = []\n if ext.get('voltage_ratings'):\n parts.append(f\"Vmax={ext['voltage_ratings']}\")\n if ext.get('operating_temp'):\n parts.append(f\"Temp={ext['operating_temp']}\")\n if ext.get('package'):\n parts.append(f\"Package={ext['package']}\")\n return '; '.join(parts) if parts else '(extraction available)'\n\n\n# ======================================================================\n# SPICE notes\n# ======================================================================\n\ndef build_spice_notes(section_type: str, analysis: dict,\n spice_data: dict | None) -> str:\n \"\"\"Build SPICE simulation notes relevant to a section.\"\"\"\n if not spice_data:\n return ''\n\n results = spice_data.get('simulation_results', [])\n if not results:\n return ''\n\n parts = []\n for r in results:\n subcircuit_type = r.get('subcircuit_type', '')\n # Match SPICE results to section type\n relevant = False\n if section_type == 'analog_design' and subcircuit_type in ('filter', 'divider', 'opamp'):\n relevant = True\n elif section_type == 'power_design' and subcircuit_type in ('regulator', 'lc_filter'):\n relevant = True\n\n if relevant:\n parts.append(\n f\"SPICE {r.get('name', '?')}: \"\n f\"measured={r.get('measured_value', '?')}, \"\n f\"expected={r.get('expected_value', '?')}, \"\n f\"{'PASS' if r.get('pass') else 'FAIL'}\"\n )\n\n return '\\n'.join(parts)\n\n\n# ======================================================================\n# Cross-references\n# ======================================================================\n\ndef build_cross_references(section_type: str, analysis: dict,\n emc_data: dict | None = None,\n thermal_data: dict | None = None,\n pcb_data: dict | None = None) -> str:\n \"\"\"Brief references to related sections.\"\"\"\n parts = []\n\n if section_type == 'power_design':\n if thermal_data:\n s = thermal_data.get('summary', {})\n parts.append(f\"See Thermal: score {s.get('thermal_score', '?')}/100, \"\n f\"{s.get('components_above_85c', 0)} above 85C\")\n if emc_data:\n dc_findings = [f for f in emc_data.get('findings', [])\n if f.get('category') == 'decoupling' and not f.get('suppressed')]\n if dc_findings:\n parts.append(f\"See EMC: {len(dc_findings)} decoupling finding(s)\")\n\n elif section_type == 'emc_analysis':\n regs = [f for f in analysis.get('findings', [])\n if f.get('detector') == 'detect_power_regulators']\n if regs:\n parts.append(f\"See Power: {len(regs)} regulator(s)\")\n if pcb_data:\n parts.append(f\"See PCB: {pcb_data.get('statistics', {}).get('copper_layers_used', '?')} layers\")\n\n elif section_type == 'thermal_analysis':\n regs = [f for f in analysis.get('findings', [])\n if f.get('detector') == 'detect_power_regulators']\n pdiss_regs = [r for r in regs if r.get('power_dissipation')]\n if pdiss_regs:\n parts.append(f\"See Power: {len(pdiss_regs)} regulator(s) with dissipation data\")\n\n elif section_type == 'executive_summary':\n if emc_data:\n s = emc_data.get('summary', {})\n parts.append(f\"EMC: {s.get('emc_risk_score', '?')}/100\")\n if thermal_data:\n s = thermal_data.get('summary', {})\n parts.append(f\"Thermal: {s.get('thermal_score', '?')}/100\")\n\n return '\\n'.join(parts)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5510,"content_sha256":"da77170c2d32857761a9eccb4fdb85229c95f0cd02ef6ecfbd41c27d804d9169"},{"filename":"scripts/kidoc_narrative_config.py","content":"\"\"\"Narrative configuration: section titles, writing guidance, and questions.\n\nPure data module — no logic, no imports. Used by kidoc_narrative.py\nto populate LLM context packages.\n\"\"\"\n\nfrom __future__ import annotations\n\n\n# ======================================================================\n# Section title mapping\n# ======================================================================\n\nSECTION_TITLES = {\n 'system_overview': 'System Overview',\n 'power_design': 'Power System Design',\n 'signal_interfaces': 'Signal Interfaces',\n 'analog_design': 'Analog Design',\n 'thermal_analysis': 'Thermal Analysis',\n 'emc_analysis': 'EMC Considerations',\n 'pcb_design': 'PCB Design Details',\n 'bom_summary': 'BOM Summary',\n 'test_debug': 'Test and Debug',\n 'executive_summary': 'Executive Summary',\n 'compliance': 'Compliance and Standards',\n 'mechanical_environmental': 'Mechanical / Environmental',\n # CE Technical File\n 'ce_product_identification': 'Product Identification',\n 'ce_essential_requirements': 'Essential Requirements',\n 'ce_risk_assessment': 'Risk Assessment',\n # Design Review\n 'review_summary': 'Review Summary',\n 'review_action_items': 'Action Items',\n # ICD\n 'icd_interface_list': 'Interface List',\n 'icd_connector_details': 'Connector Details',\n 'icd_electrical_characteristics': 'Electrical Characteristics',\n # Manufacturing\n 'mfg_assembly_overview': 'Assembly Overview',\n 'mfg_pcb_fab_notes': 'PCB Fabrication Notes',\n 'mfg_assembly_instructions': 'Assembly Instructions',\n 'mfg_test_procedures': 'Production Test Procedures',\n}\n\n\n# ======================================================================\n# Per-section writing guidance\n# ======================================================================\n\nWRITING_GUIDANCE = {\n 'system_overview': (\n \"Write a concise overview of the system architecture. Explain what the \"\n \"board does, its key functional blocks, and how they connect. Reference \"\n \"specific component counts and key ICs by part number. Keep it to 2-3 \"\n \"paragraphs. Don't repeat the data table — explain what it means.\"\n ),\n 'power_design': (\n \"Explain the power distribution architecture. For each regulator, state \"\n \"the input source, output voltage, topology, and why that topology was \"\n \"chosen (efficiency for buck, simplicity for LDO). Reference specific \"\n \"component values and datasheet recommendations. Flag any deviations \"\n \"from reference designs. Discuss thermal considerations for high-power \"\n \"regulators.\"\n ),\n 'signal_interfaces': (\n \"Describe each communication bus: what devices are connected, what \"\n \"protocol is used, and any notable configuration (pull-up values, \"\n \"termination, address assignments). Reference specific component \"\n \"references and net names.\"\n ),\n 'analog_design': (\n \"For each analog subcircuit (filters, dividers, opamp stages), explain \"\n \"the design intent, calculated performance (cutoff frequency, gain, \"\n \"output voltage), and any SPICE validation results. Use quantitative \"\n \"language — 'the RC filter sets a -3dB point at 1.02 kHz' not \"\n \"'appropriate filtering is provided.'\"\n ),\n 'thermal_analysis': (\n \"Summarize thermal analysis results. Identify components with the \"\n \"smallest thermal margins. Discuss the adequacy of thermal management \"\n \"(heat sinking, copper area, airflow). Reference specific junction \"\n \"temperatures and maximum ratings.\"\n ),\n 'emc_analysis': (\n \"Summarize EMC findings by severity. Highlight critical and high-risk \"\n \"findings with specific mitigation recommendations. Reference rule IDs \"\n \"and affected components. Discuss the overall EMC risk level and \"\n \"readiness for pre-compliance testing.\"\n ),\n 'pcb_design': (\n \"Describe the PCB stackup, layer usage, and key routing decisions. \"\n \"Reference board dimensions, layer count, and critical design rules. \"\n \"Discuss any DFM concerns.\"\n ),\n 'bom_summary': (\n \"Summarize the BOM: total unique parts, component types breakdown, \"\n \"any missing MPNs that need resolution. Note any single-source or \"\n \"long-lead-time components if known.\"\n ),\n 'test_debug': (\n \"Describe the test and debug strategy: available debug interfaces, \"\n \"test point placement, production test sequence, and programming \"\n \"access. Reference specific connector references and protocols.\"\n ),\n 'executive_summary': (\n \"Write a 1-2 paragraph executive summary. State what the board is, \"\n \"its key specifications, and the overall assessment (design maturity, \"\n \"risk level, readiness for next phase). Reference specific numbers \"\n \"from the analysis. This is the most important section — it's what \"\n \"decision-makers read.\"\n ),\n 'compliance': (\n \"List applicable standards and certification requirements. Discuss \"\n \"pre-compliance test results and gaps. Reference EMC risk score \"\n \"and specific findings that affect certification.\"\n ),\n 'mechanical_environmental': (\n \"Describe the physical design: board dimensions, mounting method, \"\n \"enclosure constraints, connector placement. State the operating \"\n \"temperature range and environmental requirements.\"\n ),\n # CE Technical File\n 'ce_product_identification': (\n \"Describe the product's intended use, target environment \"\n \"(indoor/outdoor, industrial/consumer), and user profile.\"\n ),\n 'ce_essential_requirements': (\n \"For each directive, describe how the design meets the essential \"\n \"requirements. Reference test reports, analysis data, and specific \"\n \"design features that ensure compliance.\"\n ),\n 'ce_risk_assessment': (\n \"Describe risk mitigation measures for each identified hazard. \"\n \"Reference specific design features, test results, and component \"\n \"ratings that address each risk.\"\n ),\n # Design Review\n 'review_summary': (\n \"Provide an overall assessment of design readiness. Highlight \"\n \"critical risks, summarize analyzer scores, and recommend go/no-go \"\n \"for the next design phase.\"\n ),\n 'review_action_items': (\n \"List action items from the review. Assign severity, owners, and \"\n \"due dates. Prioritize items that block fabrication.\"\n ),\n # ICD\n 'icd_connector_details': (\n \"For each connector, describe the interface protocol, signal levels, \"\n \"timing requirements, and mating connector specification.\"\n ),\n 'icd_electrical_characteristics': (\n \"Specify voltage levels, impedance, current limits, and timing \"\n \"requirements for each interface.\"\n ),\n # Manufacturing\n 'mfg_assembly_overview': (\n \"Describe assembly requirements: lead-free/leaded process, reflow \"\n \"profile, hand-solder requirements, special handling instructions.\"\n ),\n 'mfg_pcb_fab_notes': (\n \"Specify impedance control requirements, stackup details, material \"\n \"(FR-4/Rogers), and any special fabrication instructions.\"\n ),\n 'mfg_assembly_instructions': (\n \"Describe the assembly sequence: paste application, component \"\n \"placement, reflow, hand-solder steps, cleaning, conformal coating.\"\n ),\n 'mfg_test_procedures': (\n \"Describe pass/fail criteria for each test step. Include expected \"\n \"voltages, test fixture requirements, and failure modes.\"\n ),\n}\n\n\n# ======================================================================\n# Section questions — specific questions to address per section\n# ======================================================================\n\nSECTION_QUESTIONS = {\n 'system_overview': [\n \"What is the primary function of this board?\",\n \"What are the key functional blocks and how do they interconnect?\",\n \"What are the main ICs and their roles?\",\n ],\n 'power_design': [\n \"What is the input voltage source and range?\",\n \"Why was each regulator topology chosen (LDO vs. buck vs. boost)?\",\n \"Are output capacitor values consistent with datasheet recommendations?\",\n \"What is the worst-case power dissipation in each regulator?\",\n \"Is there adequate input decoupling?\",\n ],\n 'signal_interfaces': [\n \"What communication protocols are used and between which devices?\",\n \"Are pull-up/termination resistor values appropriate for the bus speed?\",\n \"Is there adequate ESD protection on external interfaces?\",\n ],\n 'analog_design': [\n \"What is the design intent of each analog subcircuit?\",\n \"Do calculated values (cutoff, gain, ratio) match the design targets?\",\n \"Have tolerances been analyzed for critical circuits?\",\n ],\n 'thermal_analysis': [\n \"Which components have the smallest thermal margin?\",\n \"Is the total board dissipation manageable without forced airflow?\",\n \"Are thermal vias or heatsinks needed for any component?\",\n ],\n 'emc_analysis': [\n \"What is the overall EMC risk level?\",\n \"Which findings are most likely to cause certification failure?\",\n \"What are the top mitigation priorities?\",\n ],\n 'pcb_design': [\n \"Is the layer count adequate for the routing complexity?\",\n \"Are there impedance-controlled traces that need stackup specification?\",\n \"Are there any DFM violations or concerns?\",\n ],\n 'bom_summary': [\n \"How many unique parts are there and is this reasonable for the design?\",\n \"Are there missing MPNs that need resolution before ordering?\",\n \"Are there any single-source or long-lead-time components?\",\n ],\n 'executive_summary': [\n \"What does this board do in one sentence?\",\n \"What is the design maturity level (prototype, pre-production, production)?\",\n \"What are the top risks or open items?\",\n ],\n}\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10162,"content_sha256":"d8d7718f897fe18bf13cd30ba22c8e7e559ef845afbd1e4ed89e5a60ed02b349"},{"filename":"scripts/kidoc_narrative_extractors.py","content":"\"\"\"Narrative data extractors — pull focused data from analysis JSON.\n\nEach extractor takes the analysis dict (plus optional keyword args for\nsupplemental data sources) and returns a concise text summary for one\nreport section type.\n\nZero external dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom finding_schema import Det, group_findings\n\n\n# ======================================================================\n# Utility\n# ======================================================================\n\ndef _format_freq(hz) -> str:\n \"\"\"Format frequency value for display.\"\"\"\n if hz is None:\n return '?'\n try:\n hz = float(hz)\n except (TypeError, ValueError):\n return str(hz)\n if hz >= 1e9:\n return f\"{hz/1e9:.2f}GHz\"\n if hz >= 1e6:\n return f\"{hz/1e6:.2f}MHz\"\n if hz >= 1e3:\n return f\"{hz/1e3:.2f}kHz\"\n return f\"{hz:.2f}Hz\"\n\n\n# ======================================================================\n# Section data extractors\n# ======================================================================\n\ndef _extract_overview_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract system overview data as concise text summary.\"\"\"\n parts = []\n stats = analysis.get('statistics', {})\n if stats:\n parts.append(\n f\"Components: {stats.get('total_components', 0)} total, \"\n f\"{stats.get('unique_parts', 0)} unique\"\n )\n parts.append(f\"Nets: {stats.get('total_nets', 0)}\")\n parts.append(f\"Sheets: {stats.get('sheets', 1)}\")\n\n types = stats.get('component_types', {})\n if types:\n type_str = ', '.join(f\"{v} {k}\" for k, v in\n sorted(types.items(), key=lambda x: -x[1]))\n parts.append(f\"Component types: {type_str}\")\n\n missing = stats.get('missing_mpn', [])\n if missing:\n parts.append(f\"Missing MPNs: {len(missing)} components ({', '.join(missing[:5])}\"\n + (f\" +{len(missing)-5}\" if len(missing) > 5 else \"\") + \")\")\n\n # Key ICs\n components = analysis.get('components', [])\n ics = [c for c in components if c.get('type') == 'ic']\n if ics:\n ic_list = [f\"{c.get('reference', '?')} ({c.get('value', '?')})\" for c in ics[:8]]\n parts.append(f\"Key ICs: {', '.join(ic_list)}\")\n\n # Power rails\n rails = stats.get('power_rails', [])\n if rails:\n rail_names = [r.get('name', '?') for r in rails if r.get('name')]\n parts.append(f\"Power rails: {', '.join(rail_names)}\")\n\n # Title block\n tb = analysis.get('title_block', {})\n if tb.get('title'):\n parts.insert(0, f\"Project: {tb['title']}\")\n\n return '\\n'.join(parts) if parts else 'No system overview data available.'\n\n\n\n\ndef _extract_power_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract power design data as concise text summary.\"\"\"\n parts = []\n _sa = group_findings(analysis)\n regs = _sa.get(Det.POWER_REGULATORS, [])\n if regs:\n parts.append(f\"{len(regs)} voltage regulator(s):\")\n for r in regs:\n line = f\" - {r.get('ref', '?')}: {r.get('value', '?')}\"\n line += f\", topology={r.get('topology', '?')}\"\n if r.get('estimated_vout'):\n line += f\", Vout={r['estimated_vout']:.3f}V\"\n line += f\", input={r.get('input_rail', '?')}\"\n line += f\", output={r.get('output_rail', '?')}\"\n\n # Feedback divider\n fb = r.get('feedback_divider')\n if fb:\n line += (f\", feedback R_top={fb.get('r_top', {}).get('ref', '?')}\"\n f\"({fb.get('r_top', {}).get('value', '?')})\"\n f\" R_bot={fb.get('r_bottom', {}).get('ref', '?')}\"\n f\"({fb.get('r_bottom', {}).get('value', '?')})\")\n\n # Input/output caps\n in_caps = r.get('input_capacitors', [])\n out_caps = r.get('output_capacitors', [])\n if in_caps:\n cap_str = ', '.join(f\"{c.get('ref','?')}={c.get('value','?')}\" for c in in_caps)\n line += f\", input_caps=[{cap_str}]\"\n if out_caps:\n cap_str = ', '.join(f\"{c.get('ref','?')}={c.get('value','?')}\" for c in out_caps)\n line += f\", output_caps=[{cap_str}]\"\n\n # Power dissipation\n pdiss = r.get('power_dissipation', {})\n if pdiss:\n line += (f\", Pdiss={pdiss.get('estimated_pdiss_W', '?')}W\"\n f\" (Vin={pdiss.get('vin_estimated_V', '?')}V\"\n f\" dropout={pdiss.get('dropout_V', '?')}V)\")\n\n parts.append(line)\n\n decoupling = _sa.get(Det.DECOUPLING, [])\n if decoupling:\n if isinstance(decoupling, list) and decoupling:\n total_caps = sum(len(d.get('capacitors', [])) for d in decoupling\n if isinstance(d, dict))\n parts.append(f\"Decoupling: {len(decoupling)} group(s), {total_caps} capacitor(s)\")\n for d in decoupling:\n if isinstance(d, dict):\n ic = d.get('ic_ref') or d.get('ic') or d.get('rail', '?')\n caps = d.get('capacitors', [])\n cap_str = ', '.join(f\"{c.get('ref','?')}={c.get('value','?')}\"\n for c in caps if isinstance(c, dict))\n parts.append(f\" - {ic}: [{cap_str}]\")\n elif isinstance(decoupling, dict):\n parts.append(f\"Decoupling: {decoupling.get('total_caps', 0)} capacitor(s)\")\n\n # Protection devices\n protection = _sa.get(Det.PROTECTION_DEVICES, [])\n if protection:\n parts.append(f\"Protection devices: {len(protection)}\")\n for p in protection[:5]:\n parts.append(f\" - {p.get('ref', '?')}: {p.get('value', '?')} ({p.get('type', '?')})\")\n\n return '\\n'.join(parts) if parts else 'No power design data available.'\n\n\ndef _extract_signal_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract signal interface data as concise text summary.\"\"\"\n parts = []\n\n bus_analysis = analysis.get('design_analysis', {}).get('bus_analysis', {})\n for bus_type in ('i2c', 'spi', 'uart', 'can'):\n buses = bus_analysis.get(bus_type, [])\n for bus in buses:\n signals = bus.get('signals', [])\n sig_names = [s.get('name', str(s)) if isinstance(s, dict) else str(s)\n for s in signals]\n if sig_names and any(s for s in sig_names):\n bus_id = bus.get('bus_id', bus_type)\n parts.append(f\"{bus_type.upper()} {bus_id}: {', '.join(sig_names[:10])}\")\n\n # Level shifters\n _sa = group_findings(analysis)\n shifters = _sa.get(Det.LEVEL_SHIFTERS, [])\n if shifters:\n parts.append(f\"Level shifters: {len(shifters)}\")\n for s in shifters:\n parts.append(f\" - {s.get('ref', '?')}: {s.get('value', '')} \"\n f\"({s.get('low_side_rail', '?')} \u003c-> {s.get('high_side_rail', '?')})\")\n\n # ESD coverage\n esd = _sa.get(Det.ESD_AUDIT, [])\n if esd:\n unprotected = [e for e in esd if isinstance(e, dict) and e.get('coverage') == 'none']\n if unprotected:\n refs = [e.get('connector_ref', '?') for e in unprotected]\n parts.append(f\"ESD gaps: {len(unprotected)} connector(s) with no protection \"\n f\"({', '.join(refs[:5])})\")\n\n # Differential pairs\n diff_pairs = analysis.get('design_analysis', {}).get('differential_pairs', [])\n if diff_pairs:\n parts.append(f\"Differential pairs: {len(diff_pairs)}\")\n for dp in diff_pairs[:5]:\n parts.append(f\" - {dp.get('name', '?')}: \"\n f\"{dp.get('positive_net', '?')} / {dp.get('negative_net', '?')}\")\n\n if not parts:\n parts.append('No formal buses or interfaces detected.')\n\n return '\\n'.join(parts)\n\n\ndef _extract_analog_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract analog design data as concise text summary.\"\"\"\n parts = []\n sa = group_findings(analysis)\n\n # Voltage dividers\n dividers = sa.get(Det.VOLTAGE_DIVIDERS, [])\n if dividers:\n parts.append(f\"{len(dividers)} voltage divider(s):\")\n for d in dividers:\n r_top = d.get('r_top', {})\n r_bot = d.get('r_bottom', {})\n parts.append(\n f\" - {r_top.get('ref', '?')}({r_top.get('value', '?')}) / \"\n f\"{r_bot.get('ref', '?')}({r_bot.get('value', '?')}), \"\n f\"ratio={d.get('ratio', '?'):.4f}, \"\n f\"top_net={d.get('top_net', '?')}, \"\n f\"mid_net={d.get('mid_net', '?')}, \"\n f\"bottom_net={d.get('bottom_net', '?')}\"\n )\n connections = d.get('mid_point_connections', [])\n if connections:\n conn_str = ', '.join(\n f\"{c.get('component', '?')}.{c.get('pin_name', '?')}\"\n for c in connections if isinstance(c, dict)\n )\n parts.append(f\" connects to: {conn_str}\")\n\n # Filters\n for ftype, label in [(Det.RC_FILTERS, 'RC filter'), (Det.LC_FILTERS, 'LC filter')]:\n filters = sa.get(ftype, [])\n if filters:\n parts.append(f\"{len(filters)} {label}(s):\")\n for f in filters:\n r = f.get('resistor', {})\n c = f.get('capacitor', {})\n r_ref = r.get('ref', '?') if isinstance(r, dict) else str(r)\n c_ref = c.get('ref', '?') if isinstance(c, dict) else str(c)\n fc = f.get('cutoff_hz')\n fc_str = _format_freq(fc) if fc else '?'\n parts.append(\n f\" - {f.get('type', '?')}: {r_ref} + {c_ref}, \"\n f\"fc={fc_str}, \"\n f\"input={f.get('input_net', '?')}, output={f.get('output_net', '?')}\"\n )\n\n # Opamp circuits\n opamps = sa.get(Det.OPAMP_CIRCUITS, [])\n if opamps:\n parts.append(f\"{len(opamps)} opamp circuit(s):\")\n for o in opamps:\n parts.append(\n f\" - {o.get('ref', '?')} ({o.get('value', '?')}): \"\n f\"topology={o.get('topology', '?')}, gain={o.get('gain', '?')}\"\n )\n\n # Crystal circuits\n crystals = sa.get(Det.CRYSTAL_CIRCUITS, [])\n if crystals:\n parts.append(f\"{len(crystals)} crystal circuit(s):\")\n for c in crystals:\n freq = c.get('frequency_hz')\n parts.append(f\" - {c.get('ref', '?')}: {_format_freq(freq) if freq else '?'}\")\n\n if not parts:\n parts.append('No analog subcircuits detected.')\n\n return '\\n'.join(parts)\n\n\ndef _extract_thermal_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract thermal analysis data as concise text summary.\"\"\"\n thermal_data = kwargs.get('thermal_data')\n if not thermal_data:\n return 'No thermal analysis data available.'\n\n parts = []\n summary = thermal_data.get('summary', {})\n parts.append(f\"Thermal score: {summary.get('thermal_score', '?')}/100\")\n parts.append(f\"Total board dissipation: {summary.get('total_board_dissipation_w', '?')}W\")\n parts.append(f\"Ambient: {summary.get('ambient_c', 25.0)}C\")\n\n hottest = summary.get('hottest_component', {})\n if isinstance(hottest, dict):\n parts.append(f\"Hottest: {hottest.get('ref', '?')} at {hottest.get('tj_estimated_c', '?')}C\")\n elif hottest:\n parts.append(f\"Hottest: {hottest}\")\n\n above_85 = summary.get('components_above_85c', 0)\n parts.append(f\"Components above 85C: {above_85}\")\n\n assessments = thermal_data.get('thermal_assessments', [])\n if assessments:\n parts.append(f\"\\n{len(assessments)} thermal assessment(s):\")\n for a in assessments:\n parts.append(\n f\" - {a.get('ref', '?')} ({a.get('value', '?')}): \"\n f\"Pdiss={a.get('pdiss_w', 0):.2f}W, \"\n f\"package={a.get('package', '?')}, \"\n f\"Rth_JA={a.get('rtheta_ja_effective', '?')}C/W, \"\n f\"Tj={a.get('tj_estimated_c', 0):.1f}C, \"\n f\"Tj_max={a.get('tj_max_c', '?')}C, \"\n f\"margin={a.get('margin_c', 0):.1f}C\"\n )\n\n findings = thermal_data.get('findings', [])\n if findings:\n parts.append(f\"\\n{len(findings)} thermal finding(s):\")\n for f in findings[:5]:\n parts.append(f\" - [{f.get('severity', '?')}] {f.get('title', '?')}\")\n\n return '\\n'.join(parts)\n\n\ndef _extract_emc_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract EMC analysis data as concise text summary.\"\"\"\n emc_data = kwargs.get('emc_data')\n if not emc_data:\n return 'No EMC analysis data available.'\n\n parts = []\n summary = emc_data.get('summary', {})\n parts.append(f\"EMC risk score: {summary.get('emc_risk_score', '?')}/100\")\n parts.append(\n f\"Findings: {summary.get('critical', 0)} critical, \"\n f\"{summary.get('high', 0)} high, \"\n f\"{summary.get('medium', 0)} medium, \"\n f\"{summary.get('low', 0)} low\"\n )\n parts.append(f\"Target standard: {emc_data.get('target_standard', '?')}\")\n\n findings = emc_data.get('findings', [])\n active = [f for f in findings if not f.get('suppressed')]\n if active:\n sev_order = {'CRITICAL': 0, 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3, 'INFO': 4}\n active.sort(key=lambda x: sev_order.get(x.get('severity', 'INFO'), 5))\n\n # Group by category\n by_cat: dict[str, int] = {}\n for f in active:\n cat = f.get('category', 'other')\n by_cat[cat] = by_cat.get(cat, 0) + 1\n parts.append(f\"\\nCategories: {', '.join(f'{c}({n})' for c, n in sorted(by_cat.items()))}\")\n\n # Top findings\n top = [f for f in active if f.get('severity') in ('CRITICAL', 'HIGH')]\n if top:\n parts.append(f\"\\n{len(top)} critical/high finding(s):\")\n for f in top[:10]:\n parts.append(\n f\" - [{f.get('severity')}] {f.get('rule_id', '?')}: \"\n f\"{f.get('title', '?')}\"\n )\n if f.get('recommendation'):\n parts.append(f\" Recommendation: {f['recommendation'][:120]}\")\n\n return '\\n'.join(parts)\n\n\ndef _extract_pcb_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract PCB design data as concise text summary.\"\"\"\n pcb_data = kwargs.get('pcb_data')\n if not pcb_data:\n return 'No PCB analysis data available.'\n\n parts = []\n stats = pcb_data.get('statistics', {})\n if stats:\n parts.append(f\"Copper layers: {stats.get('copper_layers_used', '?')}\")\n parts.append(f\"Footprints: {stats.get('footprint_count', '?')} \"\n f\"(front={stats.get('front_side', '?')}, back={stats.get('back_side', '?')})\")\n parts.append(f\"SMD: {stats.get('smd_count', '?')}, THT: {stats.get('tht_count', '?')}\")\n parts.append(f\"Tracks: {stats.get('track_segments', '?')} segments, \"\n f\"{stats.get('total_track_length_mm', '?')}mm total\")\n parts.append(f\"Vias: {stats.get('via_count', '?')}\")\n parts.append(f\"Zones: {stats.get('zone_count', '?')}\")\n parts.append(f\"Board area: {stats.get('board_width_mm', '?')}mm x \"\n f\"{stats.get('board_height_mm', '?')}mm \"\n f\"({stats.get('board_area_mm2', '?')}mm2)\")\n if stats.get('routing_complete') is not None:\n parts.append(f\"Routing: {'complete' if stats['routing_complete'] else 'incomplete'}\")\n\n layers = pcb_data.get('layers', [])\n copper_layers = [l for l in layers if l.get('type') == 'signal']\n if copper_layers:\n parts.append(f\"Layer names: {', '.join(l.get('name', '?') for l in copper_layers)}\")\n\n # DFM — violations are now in findings[], summary in dfm_summary\n dfm_summary = pcb_data.get('dfm_summary', {})\n dfm_violations = [f for f in pcb_data.get('findings', [])\n if isinstance(f, dict) and f.get('category') == 'dfm']\n if dfm_violations:\n parts.append(f\"\\nDFM violations: {len(dfm_violations)}\")\n for v in dfm_violations[:5]:\n parts.append(f\" - {v.get('rule_id', '?')}: {v.get('summary', v.get('message', '?'))}\")\n elif dfm_summary.get('violation_count', 0) > 0:\n parts.append(f\"\\nDFM violations: {dfm_summary['violation_count']}\")\n\n return '\\n'.join(parts)\n\n\ndef _extract_bom_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract BOM data as concise text summary.\"\"\"\n parts = []\n\n stats = analysis.get('statistics', {})\n parts.append(f\"Total components: {stats.get('total_components', '?')}\")\n parts.append(f\"Unique parts: {stats.get('unique_parts', '?')}\")\n\n types = stats.get('component_types', {})\n if types:\n type_str = ', '.join(f\"{k}: {v}\" for k, v in\n sorted(types.items(), key=lambda x: -x[1]))\n parts.append(f\"By type: {type_str}\")\n\n missing_mpn = stats.get('missing_mpn', [])\n if missing_mpn:\n parts.append(f\"Missing MPNs: {len(missing_mpn)} ({', '.join(missing_mpn[:8])}\"\n + (f\" +{len(missing_mpn)-8}\" if len(missing_mpn) > 8 else \"\") + \")\")\n\n dnp = stats.get('dnp_parts', 0)\n if dnp:\n parts.append(f\"DNP parts: {dnp}\")\n\n bom = analysis.get('bom', [])\n if bom:\n # Count parts with/without MPN\n with_mpn = sum(1 for b in bom if b.get('mpn'))\n parts.append(f\"BOM lines: {len(bom)} ({with_mpn} with MPN)\")\n\n return '\\n'.join(parts) if parts else 'No BOM data available.'\n\n\ndef _extract_test_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract test and debug data as concise text summary.\"\"\"\n parts = []\n sa = group_findings(analysis)\n\n debug = sa.get(Det.DEBUG_INTERFACES, [])\n if debug:\n parts.append(f\"{len(debug)} debug interface(s):\")\n for d in debug:\n parts.append(f\" - {d.get('ref', '?')}: {d.get('type', '?')} ({d.get('protocol', '?')})\")\n\n # LED indicators\n leds = sa.get(Det.LED_AUDIT, [])\n if leds:\n parts.append(f\"{len(leds)} LED indicator(s)\")\n\n if not parts:\n parts.append('No debug interfaces detected.')\n\n return '\\n'.join(parts)\n\n\ndef _extract_executive_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract executive summary data combining all sources.\"\"\"\n parts = []\n\n # Core stats\n stats = analysis.get('statistics', {})\n tb = analysis.get('title_block', {})\n if tb.get('title'):\n parts.append(f\"Project: {tb['title']}\")\n parts.append(\n f\"Design: {stats.get('total_components', '?')} components, \"\n f\"{stats.get('unique_parts', '?')} unique, \"\n f\"{stats.get('total_nets', '?')} nets\"\n )\n\n # Key ICs\n components = analysis.get('components', [])\n ics = [c for c in components if c.get('type') == 'ic']\n if ics:\n parts.append(f\"Key ICs: {', '.join(c.get('value', '?') for c in ics[:5])}\")\n\n # Power summary\n regs = group_findings(analysis).get(Det.POWER_REGULATORS, [])\n if regs:\n rails = []\n for r in regs:\n vout = r.get('estimated_vout')\n rail = r.get('output_rail') or '?'\n if vout:\n rails.append(f\"{rail} ({vout:.1f}V)\")\n else:\n rails.append(rail)\n parts.append(f\"Power rails: {', '.join(rails)}\")\n\n # EMC\n emc_data = kwargs.get('emc_data')\n if emc_data:\n emc_sum = emc_data.get('summary', {})\n parts.append(\n f\"EMC risk: {emc_sum.get('emc_risk_score', '?')}/100 \"\n f\"({emc_sum.get('critical', 0)}C/{emc_sum.get('high', 0)}H/\"\n f\"{emc_sum.get('medium', 0)}M)\"\n )\n\n # Thermal\n thermal_data = kwargs.get('thermal_data')\n if thermal_data:\n t_sum = thermal_data.get('summary', {})\n parts.append(f\"Thermal score: {t_sum.get('thermal_score', '?')}/100\")\n\n # PCB\n pcb_data = kwargs.get('pcb_data')\n if pcb_data:\n pcb_stats = pcb_data.get('statistics', {})\n parts.append(\n f\"PCB: {pcb_stats.get('copper_layers_used', '?')} layers, \"\n f\"{pcb_stats.get('board_width_mm', '?')}x{pcb_stats.get('board_height_mm', '?')}mm\"\n )\n\n # Missing MPNs\n missing = stats.get('missing_mpn', [])\n if missing:\n parts.append(f\"Missing MPNs: {len(missing)}\")\n\n return '\\n'.join(parts)\n\n\ndef _extract_compliance_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract compliance-relevant data.\"\"\"\n parts = []\n\n emc_data = kwargs.get('emc_data')\n if emc_data:\n parts.append(f\"Target standard: {emc_data.get('target_standard', '?')}\")\n summary = emc_data.get('summary', {})\n parts.append(f\"EMC risk score: {summary.get('emc_risk_score', '?')}/100\")\n parts.append(\n f\"Critical: {summary.get('critical', 0)}, High: {summary.get('high', 0)}\"\n )\n\n esd = group_findings(analysis).get(Det.ESD_AUDIT, [])\n if esd:\n unprotected = [e for e in esd if isinstance(e, dict) and e.get('coverage') == 'none']\n parts.append(f\"ESD: {len(esd)} connectors audited, {len(unprotected)} unprotected\")\n\n if not parts:\n parts.append('No compliance data available.')\n\n return '\\n'.join(parts)\n\n\ndef _extract_mechanical_data(analysis: dict, **kwargs) -> str:\n \"\"\"Extract mechanical/environmental data.\"\"\"\n parts = []\n pcb_data = kwargs.get('pcb_data')\n if pcb_data:\n outline = pcb_data.get('board_outline', {})\n if outline:\n parts.append(f\"Board: {outline.get('width_mm', '?')}mm x \"\n f\"{outline.get('height_mm', '?')}mm\")\n stats = pcb_data.get('statistics', {})\n parts.append(f\"Footprints: front={stats.get('front_side', '?')}, \"\n f\"back={stats.get('back_side', '?')}\")\n\n if not parts:\n parts.append('No mechanical data available.')\n\n return '\\n'.join(parts)\n\n\n# ======================================================================\n# Extractor registry\n# ======================================================================\n\nSECTION_DATA_EXTRACTORS = {\n 'system_overview': _extract_overview_data,\n 'power_design': _extract_power_data,\n 'signal_interfaces': _extract_signal_data,\n 'analog_design': _extract_analog_data,\n 'thermal_analysis': _extract_thermal_data,\n 'emc_analysis': _extract_emc_data,\n 'pcb_design': _extract_pcb_data,\n 'bom_summary': _extract_bom_data,\n 'test_debug': _extract_test_data,\n 'executive_summary': _extract_executive_data,\n 'compliance': _extract_compliance_data,\n 'mechanical_environmental': _extract_mechanical_data,\n}\n","content_type":"text/x-python; charset=utf-8","language":"python","size":22639,"content_sha256":"bbd9bc0416511ffa4d10a170700c01a5f7f32deee0921c461deb5a1dd369132e"},{"filename":"scripts/kidoc_narrative.py","content":"#!/usr/bin/env python3\n\"\"\"Narrative context builder for kidoc engineering documentation.\n\nAssembles focused context packages for each narrative section in a report.\nThe LLM reads this context and writes\nengineering prose. This module does NOT generate prose — it prepares the\ndata the LLM needs.\n\nUsage:\n # Context for one section\n python3 kidoc_narrative.py --analysis schematic.json --section power_design\n\n # Contexts for all NARRATIVE sections in a report\n python3 kidoc_narrative.py --analysis schematic.json --report reports/HDD.md\n\n # With additional data sources\n python3 kidoc_narrative.py --analysis schematic.json --section power_design \\\n --spec spec.json --emc emc.json --thermal thermal.json --pcb pcb.json\n\nZero external dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport re\nimport sys\nfrom pathlib import Path\n\nfrom kidoc_narrative_config import (\n SECTION_TITLES,\n WRITING_GUIDANCE,\n SECTION_QUESTIONS,\n)\nfrom kidoc_narrative_extractors import SECTION_DATA_EXTRACTORS\nfrom kidoc_narrative_augment import (\n build_datasheet_notes,\n build_spice_notes,\n build_cross_references,\n)\n\n\n# ======================================================================\n# Main context builder\n# ======================================================================\n\ndef build_narrative_context(section_id: str, section_type: str,\n analysis: dict,\n spec: dict | None = None,\n extractions: dict | None = None,\n spice_data: dict | None = None,\n existing_narrative: str | None = None,\n emc_data: dict | None = None,\n thermal_data: dict | None = None,\n pcb_data: dict | None = None) -> dict:\n \"\"\"Build focused context for LLM narrative generation.\n\n Returns a dict with all the data the LLM needs to write prose for\n one section. The LLM should NOT see the full analysis JSON — only\n this focused slice.\n \"\"\"\n # Audience/tone from spec\n audience = ''\n tone = 'technical'\n questions = []\n if spec:\n audience = spec.get('audience', '')\n tone = spec.get('tone', 'technical')\n # Per-section questions from spec override defaults\n for s in spec.get('sections', []):\n if s.get('id') == section_id or s.get('type') == section_type:\n questions = s.get('questions', [])\n break\n\n if not questions:\n questions = list(SECTION_QUESTIONS.get(section_type, []))\n\n # Extract focused data\n extractor = SECTION_DATA_EXTRACTORS.get(section_type)\n if extractor:\n data_summary = extractor(\n analysis,\n emc_data=emc_data,\n thermal_data=thermal_data,\n pcb_data=pcb_data,\n )\n else:\n data_summary = 'No data extractor available for this section type.'\n\n # Datasheet notes\n datasheet_notes = build_datasheet_notes(section_type, analysis, extractions)\n\n # SPICE notes\n spice_notes = build_spice_notes(section_type, analysis, spice_data)\n\n # Cross-references\n cross_refs = build_cross_references(\n section_type, analysis,\n emc_data=emc_data,\n thermal_data=thermal_data,\n pcb_data=pcb_data,\n )\n\n # Writing guidance\n guidance = WRITING_GUIDANCE.get(section_type, '')\n\n return {\n 'section_id': section_id,\n 'section_type': section_type,\n 'section_title': SECTION_TITLES.get(section_type, section_type),\n 'audience': audience,\n 'tone': tone,\n 'questions': questions,\n 'data_summary': data_summary,\n 'datasheet_notes': datasheet_notes,\n 'spice_notes': spice_notes,\n 'existing_text': existing_narrative or '',\n 'cross_references': cross_refs,\n 'writing_guidance': guidance,\n }\n\n\n# ======================================================================\n# Batch context builder\n# ======================================================================\n\n# Pattern matching narrative placeholders in scaffold output.\n# The scaffold emits italic placeholder text: *[hint text]*\n# Nearby headings identify the section.\n_NARRATIVE_PLACEHOLDER_RE = re.compile(r'^\\*\\[.+?\\]\\*

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…

)\n\n# Map heading text to section types\n_HEADING_TO_SECTION = {\n 'executive summary': 'executive_summary',\n 'system overview': 'system_overview',\n 'power system design': 'power_design',\n 'signal interfaces': 'signal_interfaces',\n 'analog design': 'analog_design',\n 'thermal analysis': 'thermal_analysis',\n 'emc considerations': 'emc_analysis',\n 'pcb design details': 'pcb_design',\n 'mechanical / environmental': 'mechanical_environmental',\n 'bom summary': 'bom_summary',\n 'test and debug': 'test_debug',\n 'compliance and standards': 'compliance',\n # CE\n 'product identification': 'ce_product_identification',\n 'essential requirements': 'ce_essential_requirements',\n 'risk assessment': 'ce_risk_assessment',\n # Design Review\n 'review summary': 'review_summary',\n 'action items': 'review_action_items',\n # ICD\n 'interface list': 'icd_interface_list',\n 'connector details': 'icd_connector_details',\n 'electrical characteristics': 'icd_electrical_characteristics',\n # Manufacturing\n 'assembly overview': 'mfg_assembly_overview',\n 'pcb fabrication notes': 'mfg_pcb_fab_notes',\n 'assembly instructions': 'mfg_assembly_instructions',\n 'production test procedures': 'mfg_test_procedures',\n}\n\n\ndef _detect_sections_from_markdown(md_text: str) -> list[dict]:\n \"\"\"Detect narrative sections from markdown scaffold.\n\n Returns list of {'section_type': str, 'existing_text': str|None}\n for each section that has a narrative placeholder or where the user\n has already written content.\n \"\"\"\n lines = md_text.split('\\n')\n sections = []\n current_section = None\n\n for line in lines:\n stripped = line.strip()\n\n # Track headings to determine current section\n if stripped.startswith('#'):\n heading_text = stripped.lstrip('#').strip()\n # Remove numbering like \"2. \" or \"## 3. \"\n heading_clean = re.sub(r'^\\d+\\.\\s*', '', heading_text).lower()\n section_type = _HEADING_TO_SECTION.get(heading_clean)\n if section_type:\n current_section = section_type\n\n # Detect narrative placeholder\n if current_section and _NARRATIVE_PLACEHOLDER_RE.match(stripped):\n sections.append({\n 'section_type': current_section,\n 'existing_text': None,\n })\n\n return sections\n\n\ndef build_all_narrative_contexts(report_md_path: str,\n analysis: dict,\n spec: dict | None = None,\n extractions: dict | None = None,\n spice_data: dict | None = None,\n emc_data: dict | None = None,\n thermal_data: dict | None = None,\n pcb_data: dict | None = None) -> list[dict]:\n \"\"\"Build contexts for all narrative sections in a report.\n\n Reads the markdown file, finds all narrative placeholder sections,\n and builds context for each.\n \"\"\"\n with open(report_md_path, 'r', encoding='utf-8') as f:\n md_text = f.read()\n\n detected = _detect_sections_from_markdown(md_text)\n\n contexts = []\n for det in detected:\n section_type = det['section_type']\n ctx = build_narrative_context(\n section_id=section_type,\n section_type=section_type,\n analysis=analysis,\n spec=spec,\n extractions=extractions,\n spice_data=spice_data,\n existing_narrative=det.get('existing_text'),\n emc_data=emc_data,\n thermal_data=thermal_data,\n pcb_data=pcb_data,\n )\n contexts.append(ctx)\n\n return contexts\n\n\n# ======================================================================\n# Output formatting\n# ======================================================================\n\ndef format_context(ctx: dict) -> str:\n \"\"\"Format a narrative context dict as readable text for the LLM.\"\"\"\n lines = []\n lines.append(f\"=== NARRATIVE CONTEXT: {ctx['section_title']} ===\")\n lines.append(f\"Section: {ctx['section_id']} (type: {ctx['section_type']})\")\n\n if ctx.get('audience'):\n lines.append(f\"Audience: {ctx['audience']}\")\n if ctx.get('tone'):\n lines.append(f\"Tone: {ctx['tone']}\")\n\n lines.append(\"\")\n lines.append(\"--- DATA SUMMARY ---\")\n lines.append(ctx.get('data_summary', '(none)'))\n\n if ctx.get('datasheet_notes'):\n lines.append(\"\")\n lines.append(\"--- DATASHEET NOTES ---\")\n lines.append(ctx['datasheet_notes'])\n\n if ctx.get('spice_notes'):\n lines.append(\"\")\n lines.append(\"--- SPICE VALIDATION ---\")\n lines.append(ctx['spice_notes'])\n\n if ctx.get('cross_references'):\n lines.append(\"\")\n lines.append(\"--- CROSS-REFERENCES ---\")\n lines.append(ctx['cross_references'])\n\n if ctx.get('existing_text'):\n lines.append(\"\")\n lines.append(\"--- EXISTING NARRATIVE (rewrite if stale) ---\")\n lines.append(ctx['existing_text'])\n\n if ctx.get('questions'):\n lines.append(\"\")\n lines.append(\"--- QUESTIONS TO ADDRESS ---\")\n for q in ctx['questions']:\n lines.append(f\" - {q}\")\n\n if ctx.get('writing_guidance'):\n lines.append(\"\")\n lines.append(\"--- WRITING GUIDANCE ---\")\n lines.append(ctx['writing_guidance'])\n\n lines.append(\"\")\n return '\\n'.join(lines)\n\n\n# ======================================================================\n# CLI\n# ======================================================================\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Build narrative context for kidoc report sections')\n parser.add_argument('--analysis', required=True,\n help='Path to schematic analysis JSON')\n parser.add_argument('--section',\n help='Build context for one section type')\n parser.add_argument('--report',\n help='Build contexts for all narrative sections in a markdown file')\n parser.add_argument('--spec',\n help='Document spec JSON (for audience/tone/questions)')\n parser.add_argument('--emc',\n help='Path to EMC analysis JSON')\n parser.add_argument('--thermal',\n help='Path to thermal analysis JSON')\n parser.add_argument('--pcb',\n help='Path to PCB analysis JSON')\n parser.add_argument('--extractions',\n help='Path to datasheet extractions directory or JSON')\n parser.add_argument('--spice',\n help='Path to SPICE results JSON')\n args = parser.parse_args()\n\n # Load analysis\n with open(args.analysis, 'r', encoding='utf-8') as f:\n analysis = json.load(f)\n\n # Load optional data sources\n spec = None\n if args.spec:\n with open(args.spec, 'r', encoding='utf-8') as f:\n spec = json.load(f)\n\n emc_data = None\n if args.emc:\n with open(args.emc, 'r', encoding='utf-8') as f:\n emc_data = json.load(f)\n else:\n # Try to find emc.json alongside the analysis\n emc_path = os.path.join(os.path.dirname(args.analysis), 'emc.json')\n if os.path.isfile(emc_path):\n with open(emc_path, 'r', encoding='utf-8') as f:\n emc_data = json.load(f)\n\n thermal_data = None\n if args.thermal:\n with open(args.thermal, 'r', encoding='utf-8') as f:\n thermal_data = json.load(f)\n else:\n thermal_path = os.path.join(os.path.dirname(args.analysis), 'thermal.json')\n if os.path.isfile(thermal_path):\n with open(thermal_path, 'r', encoding='utf-8') as f:\n thermal_data = json.load(f)\n\n pcb_data = None\n if args.pcb:\n with open(args.pcb, 'r', encoding='utf-8') as f:\n pcb_data = json.load(f)\n else:\n pcb_path = os.path.join(os.path.dirname(args.analysis), 'pcb.json')\n if os.path.isfile(pcb_path):\n with open(pcb_path, 'r', encoding='utf-8') as f:\n pcb_data = json.load(f)\n\n spice_data = None\n if args.spice:\n with open(args.spice, 'r', encoding='utf-8') as f:\n spice_data = json.load(f)\n\n extractions = None\n if args.extractions:\n ext_path = args.extractions\n if os.path.isfile(ext_path):\n with open(ext_path, 'r', encoding='utf-8') as f:\n extractions = json.load(f)\n elif os.path.isdir(ext_path):\n # Load all JSONs from directory keyed by filename stem\n extractions = {}\n for fname in os.listdir(ext_path):\n if fname.endswith('.json'):\n fpath = os.path.join(ext_path, fname)\n try:\n with open(fpath, 'r', encoding='utf-8') as f:\n extractions[fname.replace('.json', '')] = json.load(f)\n except (json.JSONDecodeError, OSError):\n pass\n\n # Build and output context\n if args.section:\n ctx = build_narrative_context(\n section_id=args.section,\n section_type=args.section,\n analysis=analysis,\n spec=spec,\n extractions=extractions,\n spice_data=spice_data,\n emc_data=emc_data,\n thermal_data=thermal_data,\n pcb_data=pcb_data,\n )\n print(format_context(ctx))\n\n elif args.report:\n if not os.path.isfile(args.report):\n print(f\"Error: report file not found: {args.report}\", file=sys.stderr)\n sys.exit(1)\n contexts = build_all_narrative_contexts(\n report_md_path=args.report,\n analysis=analysis,\n spec=spec,\n extractions=extractions,\n spice_data=spice_data,\n emc_data=emc_data,\n thermal_data=thermal_data,\n pcb_data=pcb_data,\n )\n if not contexts:\n print(\"No narrative sections found in report.\", file=sys.stderr)\n sys.exit(0)\n for ctx in contexts:\n print(format_context(ctx))\n\n else:\n # No --section or --report: list available section types\n print(\"Available section types:\")\n for stype in sorted(SECTION_DATA_EXTRACTORS.keys()):\n title = SECTION_TITLES.get(stype, stype)\n print(f\" {stype:30s} {title}\")\n print(f\"\\n{len(SECTION_DATA_EXTRACTORS)} extractors available.\")\n print(\"\\nUse --section \u003ctype> or --report \u003cfile.md> to generate context.\")\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":15102,"content_sha256":"7e55ba3d5fd0c7bf8041f37f2dc54ea51af383894319cbeee22f14b73c8453aa"},{"filename":"scripts/kidoc_odt.py","content":"#!/usr/bin/env python3\n\"\"\"ODT (OpenDocument Text) generation from kidoc markdown scaffolds.\n\nConverts markdown to ODT using odfpy. SVGs are rasterized to PNG via\nsvglib + rl-renderPM before embedding. Runs inside reports/.venv/.\n\nUsage (called by kidoc_generate.py, not directly):\n python3 kidoc_odt.py --input reports/HDD.md --output reports/output/HDD.odt\n --config '{\"project\": {\"name\": \"...\"}}'\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\n\nfrom odf.opendocument import OpenDocumentText\nfrom odf import text as odftext\nfrom odf import draw as odfdraw\nfrom odf import table as odftable\nfrom odf.style import (Style, TextProperties, ParagraphProperties,\n TableProperties, TableColumnProperties,\n TableCellProperties, GraphicProperties, FontFace)\nfrom odf.text import P, H, List, ListItem, ListLevelStyleBullet, ListStyle\nfrom odf.table import Table, TableColumn, TableRow, TableCell\n\n# Add kidoc scripts to path for sibling imports\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom kidoc_md_parser import parse_markdown\nfrom kidoc_raster import svg_to_png as _svg_to_png, has_svg_render, get_dpi\n\n\n# ======================================================================\n# Style definitions\n# ======================================================================\n\ndef _create_styles(doc):\n \"\"\"Create document styles and return a style lookup dict.\"\"\"\n styles = {}\n\n # Title\n s = Style(name='KidocTitle', family='paragraph')\n s.addElement(TextProperties(fontsize='20pt', fontweight='bold',\n color='#202020'))\n s.addElement(ParagraphProperties(marginbottom='0.3cm'))\n doc.styles.addElement(s)\n styles['title'] = s\n\n # Headings\n for level, size in [(1, '16pt'), (2, '13pt'), (3, '11pt'), (4, '10pt')]:\n s = Style(name=f'KidocH{level}', family='paragraph')\n s.addElement(TextProperties(fontsize=size, fontweight='bold',\n color='#202040'))\n s.addElement(ParagraphProperties(margintop='0.4cm',\n marginbottom='0.2cm'))\n doc.styles.addElement(s)\n styles[f'h{level}'] = s\n\n # Body\n s = Style(name='KidocBody', family='paragraph')\n s.addElement(TextProperties(fontsize='9pt', color='#000000'))\n s.addElement(ParagraphProperties(marginbottom='0.15cm'))\n doc.styles.addElement(s)\n styles['body'] = s\n\n # Code\n s = Style(name='KidocCode', family='paragraph')\n s.addElement(TextProperties(fontsize='8pt', fontfamily='Courier New',\n color='#303030'))\n s.addElement(ParagraphProperties(marginleft='0.5cm',\n marginbottom='0.2cm',\n backgroundcolor='#f5f5f5'))\n doc.styles.addElement(s)\n styles['code'] = s\n\n # Blockquote\n s = Style(name='KidocBlockquote', family='paragraph')\n s.addElement(TextProperties(fontsize='9pt', fontstyle='italic',\n color='#606060'))\n s.addElement(ParagraphProperties(marginleft='1cm',\n marginbottom='0.2cm'))\n doc.styles.addElement(s)\n styles['blockquote'] = s\n\n # Caption\n s = Style(name='KidocCaption', family='paragraph')\n s.addElement(TextProperties(fontsize='8pt', fontstyle='italic',\n color='#606060'))\n s.addElement(ParagraphProperties(textalign='center',\n marginbottom='0.3cm'))\n doc.styles.addElement(s)\n styles['caption'] = s\n\n # Bold inline\n s = Style(name='KidocBold', family='text')\n s.addElement(TextProperties(fontweight='bold'))\n doc.styles.addElement(s)\n styles['bold'] = s\n\n # Italic inline\n s = Style(name='KidocItalic', family='text')\n s.addElement(TextProperties(fontstyle='italic'))\n doc.styles.addElement(s)\n styles['italic'] = s\n\n # Code inline\n s = Style(name='KidocCodeInline', family='text')\n s.addElement(TextProperties(fontfamily='Courier New', fontsize='8pt',\n color='#c04000'))\n doc.styles.addElement(s)\n styles['code_inline'] = s\n\n # Table cell\n s = Style(name='KidocTableCell', family='table-cell')\n s.addElement(TableCellProperties(padding='0.1cm',\n border='0.5pt solid #c0c0c0'))\n doc.automaticstyles.addElement(s)\n styles['table_cell'] = s\n\n # Table header cell\n s = Style(name='KidocTableHeaderCell', family='table-cell')\n s.addElement(TableCellProperties(padding='0.1cm',\n border='0.5pt solid #c0c0c0',\n backgroundcolor='#e8e8f0'))\n doc.automaticstyles.addElement(s)\n styles['table_header_cell'] = s\n\n # Table\n s = Style(name='KidocTable', family='table')\n s.addElement(TableProperties(width='17cm', align='margins'))\n doc.automaticstyles.addElement(s)\n styles['table'] = s\n\n # Frame\n s = Style(name='KidocFrame', family='graphic')\n s.addElement(GraphicProperties(horizontalpos='center',\n horizontalrel='paragraph'))\n doc.automaticstyles.addElement(s)\n styles['frame'] = s\n\n return styles\n\n\n# ======================================================================\n# Inline formatting\n# ======================================================================\n\ndef _add_runs_to_paragraph(p, runs: list[dict], styles: dict) -> None:\n \"\"\"Add formatted inline runs to an ODF paragraph.\"\"\"\n for r in runs:\n text_content = r['text']\n if r.get('bold') and r.get('italic'):\n span = odftext.Span(stylename=styles['bold'], text='')\n inner = odftext.Span(stylename=styles['italic'], text=text_content)\n span.addElement(inner)\n p.addElement(span)\n elif r.get('bold'):\n span = odftext.Span(stylename=styles['bold'], text=text_content)\n p.addElement(span)\n elif r.get('italic'):\n span = odftext.Span(stylename=styles['italic'], text=text_content)\n p.addElement(span)\n elif r.get('code'):\n span = odftext.Span(stylename=styles['code_inline'], text=text_content)\n p.addElement(span)\n else:\n span = odftext.Span(text=text_content)\n p.addElement(span)\n\n\n# ======================================================================\n# Element conversion\n# ======================================================================\n\ndef _add_element(doc, elem: dict, base_dir: str, styles: dict,\n dpi: int, temp_files: list) -> None:\n \"\"\"Add a parsed markdown element to the ODT document.\"\"\"\n etype = elem['type']\n\n if etype == 'heading':\n level = min(elem['level'], 4)\n style_name = 'title' if level == 1 else f'h{level}'\n h = H(outlinelevel=level, stylename=styles.get(style_name, styles['body']),\n text=elem['text'])\n doc.text.addElement(h)\n\n elif etype == 'paragraph':\n p = P(stylename=styles['body'])\n _add_runs_to_paragraph(p, elem['runs'], styles)\n doc.text.addElement(p)\n\n elif etype == 'image':\n _add_image(doc, elem, base_dir, styles, dpi, temp_files)\n\n elif etype == 'table':\n _add_table(doc, elem, styles)\n\n elif etype == 'code_block':\n for line in elem['code'].split('\\n'):\n p = P(stylename=styles['code'], text=line)\n doc.text.addElement(p)\n\n elif etype == 'hr':\n p = P(stylename=styles['body'], text='—' * 40)\n doc.text.addElement(p)\n\n elif etype == 'bullet_list':\n for item_runs in elem['items']:\n p = P(stylename=styles['body'], text='• ')\n _add_runs_to_paragraph(p, item_runs, styles)\n doc.text.addElement(p)\n\n elif etype == 'numbered_list':\n for i, item_runs in enumerate(elem['items']):\n p = P(stylename=styles['body'], text=f'{i+1}. ')\n _add_runs_to_paragraph(p, item_runs, styles)\n doc.text.addElement(p)\n\n elif etype == 'blockquote':\n p = P(stylename=styles['blockquote'])\n _add_runs_to_paragraph(p, elem['runs'], styles)\n doc.text.addElement(p)\n\n\ndef _add_image(doc, elem: dict, base_dir: str, styles: dict,\n dpi: int, temp_files: list) -> None:\n \"\"\"Add an image to the ODT. SVGs are rasterized first.\"\"\"\n path = elem['path']\n if not os.path.isabs(path):\n path = os.path.join(base_dir, path)\n\n if not os.path.isfile(path):\n p = P(stylename=styles['caption'], text=f'[Image not found: {elem[\"path\"]}]')\n doc.text.addElement(p)\n return\n\n img_path = path\n if path.lower().endswith('.svg'):\n png_path = _svg_to_png(path, dpi=dpi)\n if png_path:\n img_path = png_path\n temp_files.append(png_path)\n else:\n p = P(stylename=styles['caption'],\n text=f'[SVG rendering unavailable: {elem[\"path\"]}]')\n doc.text.addElement(p)\n return\n\n try:\n # Read image to determine size\n from PIL import Image as PILImage\n with PILImage.open(img_path) as img:\n img_w, img_h = img.size\n # Scale to fit ~16cm width\n max_w_cm = 16.0\n w_cm = min(max_w_cm, img_w * 2.54 / dpi)\n h_cm = w_cm * img_h / img_w\n\n # Create frame + image\n p = P(stylename=styles['body'])\n frame = odfdraw.Frame(stylename=styles['frame'],\n width=f'{w_cm:.1f}cm', height=f'{h_cm:.1f}cm')\n href = doc.addPicture(img_path)\n img_elem = odfdraw.Image(href=href)\n frame.addElement(img_elem)\n p.addElement(frame)\n doc.text.addElement(p)\n except Exception:\n p = P(stylename=styles['caption'],\n text=f'[Failed to embed image: {elem[\"path\"]}]')\n doc.text.addElement(p)\n\n # Caption\n if elem.get('alt'):\n p = P(stylename=styles['caption'], text=elem['alt'])\n doc.text.addElement(p)\n\n\ndef _add_table(doc, elem: dict, styles: dict) -> None:\n \"\"\"Add a table to the ODT.\"\"\"\n headers = elem['headers']\n rows = elem['rows']\n n_cols = len(headers)\n\n t = Table(stylename=styles['table'])\n\n # Columns\n col_style = Style(name='KidocTableCol', family='table-column')\n col_style.addElement(TableColumnProperties(\n columnwidth=f'{17.0/n_cols:.1f}cm'))\n doc.automaticstyles.addElement(col_style)\n for _ in range(n_cols):\n t.addElement(TableColumn(stylename=col_style))\n\n # Header row\n tr = TableRow()\n for h in headers:\n tc = TableCell(stylename=styles['table_header_cell'])\n p = P(text=h)\n # Bold header text\n tc.addElement(p)\n tr.addElement(tc)\n t.addElement(tr)\n\n # Data rows\n for row in rows:\n tr = TableRow()\n for i in range(n_cols):\n cell_text = row[i] if i \u003c len(row) else ''\n tc = TableCell(stylename=styles['table_cell'])\n tc.addElement(P(text=cell_text))\n tr.addElement(tc)\n t.addElement(tr)\n\n doc.text.addElement(t)\n # Spacer\n doc.text.addElement(P(stylename=styles['body'], text=''))\n\n\n# ======================================================================\n# Main generation\n# ======================================================================\n\ndef generate_odt(markdown_path: str, output_path: str, config: dict) -> str:\n \"\"\"Convert markdown to ODT. Returns the output path.\"\"\"\n with open(markdown_path, 'r', encoding='utf-8') as f:\n md_text = f.read()\n\n elements = parse_markdown(md_text)\n base_dir = os.path.dirname(os.path.abspath(markdown_path))\n dpi = config.get('reports', {}).get('rendering', {}).get('schematic_dpi', 300)\n\n doc = OpenDocumentText()\n styles = _create_styles(doc)\n\n temp_files: list[str] = []\n\n try:\n for elem in elements:\n _add_element(doc, elem, base_dir, styles, dpi, temp_files)\n\n os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', exist_ok=True)\n doc.save(output_path)\n finally:\n for tf in temp_files:\n try:\n os.unlink(tf)\n except OSError:\n pass\n\n return output_path\n\n\ndef main():\n parser = argparse.ArgumentParser(description='Generate ODT from markdown')\n parser.add_argument('--input', '-i', required=True,\n help='Input markdown file')\n parser.add_argument('--output', '-o', required=True,\n help='Output ODT file')\n parser.add_argument('--config', '-c', default='{}',\n help='JSON config string or path to config file')\n args = parser.parse_args()\n\n if os.path.isfile(args.config):\n with open(args.config) as f:\n config = json.load(f)\n else:\n config = json.loads(args.config)\n\n output = generate_odt(args.input, args.output, config)\n print(output, file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13162,"content_sha256":"42e1e36929605242df347b0f44d42250ee97c79e21f4e48090817848968a4588"},{"filename":"scripts/kidoc_orchestrator.py","content":"#!/usr/bin/env python3\n\"\"\"Render orchestrator for kidoc document generation.\n\nCoordinates all figure generation for a report based on the document\nspec. All figures — schematic overviews, subsystem crops, PCB views,\nblock diagrams, pinouts, and analysis charts — go through the\nregistered generator framework with tracked JSON inputs.\n\nUsage:\n python3 kidoc_orchestrator.py --spec spec.json --project-dir . --output reports/figures/\n python3 kidoc_orchestrator.py --analysis schematic.json --project-dir . --output reports/figures/\n\nZero external dependencies -- Python 3.8+ stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\n\n# Ensure this script's directory is on sys.path for sibling imports\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n\nfrom kidoc_spec import load_spec, expand_type_to_spec\nfrom figures import run_all, FigureTheme\n\n\n# ======================================================================\n# File auto-detection\n# ======================================================================\n\ndef _find_file(project_dir: str, suffix: str) -> Optional[str]:\n \"\"\"Find the first file with *suffix* under *project_dir*.\"\"\"\n for f in Path(project_dir).rglob(f'*{suffix}'):\n return str(f)\n return None\n\n\n# ======================================================================\n# Main orchestrator\n# ======================================================================\n\ndef orchestrate_renders(spec: dict, project_dir: str,\n analysis: dict,\n figures_dir: str,\n sch_path: Optional[str] = None,\n pcb_path: Optional[str] = None,\n config: Optional[dict] = None\n ) -> Dict[str, List[str]]:\n \"\"\"Generate all figures for a report based on the document spec.\n\n All figure generation goes through the registered generator framework.\n The analysis dict is augmented with ``_sch_path``, ``_pcb_path``, and\n ``_spec_sections`` so generators can access project files and spec data.\n\n Args:\n spec: document spec dict (from kidoc_spec.py)\n project_dir: KiCad project directory\n analysis: loaded schematic analysis JSON\n figures_dir: base output directory (e.g., reports/figures/)\n sch_path: path to .kicad_sch (auto-detected if None)\n pcb_path: path to .kicad_pcb (auto-detected if None)\n config: project config from .kicad-happy.json\n\n Returns:\n dict mapping category -> list of generated figure paths\n \"\"\"\n config = config or {}\n\n # Auto-detect project files\n if not sch_path:\n sch_path = _find_file(project_dir, '.kicad_sch')\n if not pcb_path:\n pcb_path = _find_file(project_dir, '.kicad_pcb')\n\n # Augment analysis with paths and spec sections so generators\n # can access them via the standard prepare(analysis, config) interface\n augmented = dict(analysis) if analysis else {}\n if sch_path:\n augmented['_sch_path'] = sch_path\n if pcb_path:\n augmented['_pcb_path'] = pcb_path\n augmented['_spec_sections'] = spec.get('sections', [])\n\n # Run all generators through the framework\n print(f\" Generating figures into {figures_dir}\", file=sys.stderr)\n paths = run_all(augmented, config, figures_dir)\n\n result: Dict[str, List[str]] = {}\n if paths:\n result['_figures'] = paths\n\n return result\n\n\n# ======================================================================\n# CLI\n# ======================================================================\n\ndef main() -> None:\n parser = argparse.ArgumentParser(\n description='Render orchestrator for kidoc document generation')\n parser.add_argument('--spec', '-s', default=None,\n help='Path to document spec JSON '\n '(default: auto-generate from analysis)')\n parser.add_argument('--analysis', '-a', default=None,\n help='Path to schematic analysis JSON')\n parser.add_argument('--project-dir', '-p', required=True,\n help='KiCad project directory')\n parser.add_argument('--output', '-o', required=True,\n help='Output directory for figures')\n parser.add_argument('--sch', default=None,\n help='Path to .kicad_sch (auto-detected if omitted)')\n parser.add_argument('--pcb', default=None,\n help='Path to .kicad_pcb (auto-detected if omitted)')\n parser.add_argument('--config', default=None,\n help='Path to .kicad-happy.json config '\n '(for branding/theme)')\n parser.add_argument('--emc', default=None,\n help='Path to EMC analysis JSON')\n parser.add_argument('--thermal', default=None,\n help='Path to thermal analysis JSON')\n parser.add_argument('--spice', default=None,\n help='Path to SPICE results JSON')\n parser.add_argument('--analyze', action='store_true',\n help='Run KiCad analysis scripts if no analysis data '\n 'exists.')\n args = parser.parse_args()\n\n # Load or generate spec\n if args.spec:\n spec = load_spec(args.spec)\n else:\n spec = expand_type_to_spec('hdd')\n\n # Load analysis and merge supplemental data\n analysis = {}\n if args.analysis:\n with open(args.analysis) as f:\n analysis = json.load(f)\n\n # If no explicit analysis path, try manifest\n if not args.analysis:\n try:\n _kicad_scripts = os.path.join(os.path.dirname(os.path.abspath(__file__)),\n '..', '..', 'kicad', 'scripts')\n if os.path.isdir(_kicad_scripts):\n import sys as _sys\n _sys.path.insert(0, os.path.abspath(_kicad_scripts))\n from project_config import load_config\n _config = load_config(args.project_dir)\n _analysis_dir = os.path.join(\n args.project_dir,\n _config.get('analysis', {}).get('output_dir', 'analysis'))\n _manifest_path = os.path.join(_analysis_dir, 'manifest.json')\n if os.path.isfile(_manifest_path):\n with open(_manifest_path) as f:\n _manifest = json.load(f)\n _current_id = _manifest.get('current')\n if _current_id:\n _sch_path = os.path.join(_analysis_dir, _current_id,\n 'schematic.json')\n if os.path.isfile(_sch_path):\n args.analysis = _sch_path\n with open(_sch_path) as f:\n analysis = json.load(f)\n except (ImportError, json.JSONDecodeError, OSError):\n pass\n\n for path in (args.emc, args.thermal, args.spice):\n if path:\n with open(path) as f:\n analysis.update(json.load(f))\n\n # Load config\n config = None\n if args.config:\n with open(args.config) as f:\n config = json.load(f)\n\n figures_dir = os.path.abspath(args.output)\n project_dir = os.path.abspath(args.project_dir)\n\n print(f\"Orchestrating renders into {figures_dir}\", file=sys.stderr)\n result = orchestrate_renders(\n spec, project_dir, analysis, figures_dir,\n sch_path=args.sch, pcb_path=args.pcb, config=config,\n )\n\n # Report\n total = sum(len(v) for v in result.values())\n print(f\"\\nGenerated {total} figure(s):\", file=sys.stderr)\n for category, paths in sorted(result.items()):\n print(f\" {category}:\", file=sys.stderr)\n for p in paths:\n print(f\" {p}\", file=sys.stderr)\n\n # Output JSON manifest to stdout\n json.dump(result, sys.stdout, indent=2)\n sys.stdout.write('\\n')\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8041,"content_sha256":"4d9e414054e821966bb37db93c27bc9e314dc6b025222f98cb8e478acb06848d"},{"filename":"scripts/kidoc_pdf.py","content":"#!/usr/bin/env python3\n\"\"\"Publication-quality PDF generation from kidoc markdown scaffolds.\n\nProduces professional engineering documents with dark navy headers,\nstyled cover pages, table of contents, formatted tables with alternating\nrows, and vector SVG diagrams.\n\nStyling modeled after B&W generate_pdfs.py with engineering-document\nterminology and kidoc markdown parser integration.\n\nUsage (called by kidoc_generate.py, not directly):\n python3 kidoc_pdf.py --input reports/HDD.md --output reports/output/HDD.pdf\n --config '{\"project\": {\"name\": \"...\"}}'\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport re\nimport sys\nfrom datetime import datetime\n\n# These imports require the venv to be active\nfrom reportlab.lib.pagesizes import letter, A4\nfrom reportlab.lib.styles import ParagraphStyle\nfrom reportlab.lib.units import inch\nfrom reportlab.lib.colors import HexColor, white\nfrom reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY\nfrom reportlab.platypus import (\n Paragraph, Spacer, Table, TableStyle,\n PageBreak, Preformatted, HRFlowable, KeepTogether, Flowable,\n)\nfrom reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate, Frame, NextPageTemplate\n\n# Add kidoc scripts to path for the markdown parser and SVG embed\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom kidoc_md_parser import parse_markdown, parse_inline\nfrom figures.lib.svg_embed import svg_to_flowable\n\n\n# ======================================================================\n# Brand colors (engineering document palette)\n# ======================================================================\n\nDARK_NAVY = HexColor(\"#1a1a2e\")\nACCENT_BLUE = HexColor(\"#0f4c75\")\nACCENT_TEAL = HexColor(\"#1b6ca8\")\nLIGHT_BLUE = HexColor(\"#e8f4fc\")\nDARK_TEXT = HexColor(\"#2c2c2c\")\nMEDIUM_TEXT = HexColor(\"#555555\")\nLIGHT_TEXT = HexColor(\"#999999\")\nTABLE_HEADER_BG = HexColor(\"#1a3a5c\")\nTABLE_ALT_ROW = HexColor(\"#f4f8fb\")\nRULE_COLOR = HexColor(\"#d0d8e0\")\nCALLOUT_BG = HexColor(\"#edf5fb\")\nCALLOUT_BORDER = HexColor(\"#1b6ca8\")\nWHITE = white\n\nPAGE_SIZES = {\n 'letter': letter,\n 'a4': A4,\n}\n\n\ndef _resolve_branding(config: dict) -> dict:\n \"\"\"Resolve branding settings from config, with defaults.\"\"\"\n branding = config.get('reports', {}).get('branding', {})\n colors = branding.get('colors', {})\n\n return {\n 'dark_navy': HexColor(colors.get('primary', '#1a1a2e')),\n 'accent_blue': HexColor(colors.get('accent', '#0f4c75')),\n 'accent_teal': HexColor(colors.get('highlight', '#1b6ca8')),\n 'table_header_bg': HexColor(colors.get('table_header', '#1a3a5c')),\n 'table_alt_row': HexColor(colors.get('table_alt_row', '#f4f8fb')),\n 'callout_bg': HexColor(colors.get('callout_bg', '#edf5fb')),\n 'callout_border': HexColor(colors.get('callout_border', '#1b6ca8')),\n 'company_name': branding.get('company_name',\n config.get('project', {}).get('company', '')),\n 'logo_path': branding.get('logo', ''),\n 'header_left': branding.get('header_left', '{company}'),\n 'header_right': branding.get('header_right', '{number} Rev {rev}'),\n }\n\n\n# ======================================================================\n# Custom flowables\n# ======================================================================\n\nclass CalloutBox(Flowable):\n \"\"\"A colored box with left border accent for callouts/blockquotes.\"\"\"\n\n def __init__(self, text, width, bg_color, border_color, style,\n border_width=3):\n Flowable.__init__(self)\n self.text = text\n self.box_width = width\n self.bg_color = bg_color\n self.border_color = border_color\n self.style = style\n self.border_width = border_width\n self._para = Paragraph(text, style)\n self._para.wrap(width - 24, 10000)\n self.height = self._para.height + 16\n\n def wrap(self, availWidth, availHeight):\n self._para.wrap(self.box_width - 24, availHeight)\n self.height = self._para.height + 16\n self.width = self.box_width\n return (self.width, self.height)\n\n def draw(self):\n c = self.canv\n c.saveState()\n c.setFillColor(self.bg_color)\n c.roundRect(0, 0, self.box_width, self.height, 3, fill=1, stroke=0)\n c.setFillColor(self.border_color)\n c.rect(0, 0, self.border_width, self.height, fill=1, stroke=0)\n self._para.drawOn(c, 12 + self.border_width, 8)\n c.restoreState()\n\n\n# ======================================================================\n# Styles\n# ======================================================================\n\ndef create_styles(brand: dict | None = None):\n \"\"\"Build the complete style set for engineering documents.\"\"\"\n b = brand or {}\n dark_navy = b.get('dark_navy', DARK_NAVY)\n accent_blue = b.get('accent_blue', ACCENT_BLUE)\n accent_teal = b.get('accent_teal', ACCENT_TEAL)\n\n s = {}\n s['h1'] = ParagraphStyle(\n 'H1', fontName='Helvetica-Bold', fontSize=16, leading=20,\n textColor=dark_navy, spaceBefore=20, spaceAfter=4,\n )\n s['h2'] = ParagraphStyle(\n 'H2', fontName='Helvetica-Bold', fontSize=13, leading=16,\n textColor=accent_blue, spaceBefore=16, spaceAfter=6,\n )\n s['h3'] = ParagraphStyle(\n 'H3', fontName='Helvetica-Bold', fontSize=11, leading=14,\n textColor=accent_teal, spaceBefore=10, spaceAfter=4,\n )\n s['body'] = ParagraphStyle(\n 'Body', fontName='Helvetica', fontSize=9.5, leading=13.5,\n textColor=DARK_TEXT, spaceAfter=7, alignment=TA_JUSTIFY,\n )\n s['bullet'] = ParagraphStyle(\n 'Bullet', fontName='Helvetica', fontSize=9.5, leading=13.5,\n textColor=DARK_TEXT, spaceAfter=3, leftIndent=18, bulletIndent=6,\n )\n s['numbered'] = ParagraphStyle(\n 'Numbered', fontName='Helvetica', fontSize=9.5, leading=13.5,\n textColor=DARK_TEXT, spaceAfter=3, leftIndent=18, bulletIndent=6,\n )\n s['callout'] = ParagraphStyle(\n 'Callout', fontName='Helvetica', fontSize=9.5, leading=13,\n textColor=DARK_TEXT,\n )\n s['code'] = ParagraphStyle(\n 'Code', fontName='Courier', fontSize=7.5, leading=9.5,\n textColor=DARK_TEXT, backColor=HexColor('#f5f5f5'),\n borderWidth=0.5, borderColor=HexColor('#e0e0e0'),\n borderPadding=6, leftIndent=6, spaceAfter=8,\n )\n s['table_header'] = ParagraphStyle(\n 'TH', fontName='Helvetica-Bold', fontSize=8, leading=11,\n textColor=WHITE,\n )\n s['table_cell'] = ParagraphStyle(\n 'TC', fontName='Helvetica', fontSize=8, leading=11,\n textColor=DARK_TEXT,\n )\n s['table_cell_bold'] = ParagraphStyle(\n 'TCB', fontName='Helvetica-Bold', fontSize=8, leading=11,\n textColor=DARK_TEXT,\n )\n s['toc'] = ParagraphStyle(\n 'TOC', fontName='Helvetica', fontSize=10, leading=18,\n textColor=DARK_TEXT, leftIndent=0,\n )\n s['toc_sub'] = ParagraphStyle(\n 'TOCSub', fontName='Helvetica', fontSize=9, leading=16,\n textColor=MEDIUM_TEXT, leftIndent=16,\n )\n s['meta_label'] = ParagraphStyle(\n 'MetaLabel', fontName='Helvetica', fontSize=8.5, leading=12,\n textColor=LIGHT_TEXT,\n )\n s['meta_value'] = ParagraphStyle(\n 'MetaValue', fontName='Helvetica-Bold', fontSize=8.5, leading=12,\n textColor=DARK_TEXT,\n )\n s['figure_caption'] = ParagraphStyle(\n 'FigCaption', fontName='Helvetica-Oblique', fontSize=8, leading=11,\n textColor=MEDIUM_TEXT, spaceAfter=10, spaceBefore=2,\n alignment=TA_CENTER,\n )\n return s\n\n\n# ======================================================================\n# Document template with cover + main page templates\n# ======================================================================\n\nclass KidocDocTemplate(BaseDocTemplate):\n \"\"\"Custom document template with cover page and main page layouts.\"\"\"\n\n def __init__(self, filename, doc_title='', doc_subtitle='',\n company='', classification='', doc_date='',\n brand: dict | None = None, **kwargs):\n self.doc_title = doc_title\n self.doc_subtitle = doc_subtitle\n self.company = company\n self.classification = classification\n self.doc_date = doc_date or datetime.now().strftime(\"%B %d, %Y\")\n self.brand = brand or {}\n super().__init__(filename, **kwargs)\n\n page_w, page_h = kwargs.get('pagesize', letter)\n margin = kwargs.get('leftMargin', inch)\n content_w = page_w - 2 * margin\n\n frame = Frame(\n margin, 0.7 * inch, content_w, page_h - 1.4 * inch,\n id='normal', topPadding=6, bottomPadding=6,\n )\n self.addPageTemplates([\n PageTemplate(id='cover', frames=[frame],\n onPage=self._draw_cover_page),\n PageTemplate(id='main', frames=[frame],\n onPage=self._draw_page),\n ])\n\n def _draw_cover_page(self, canvas, doc):\n \"\"\"Dark navy header band, company name, accent line.\"\"\"\n canvas.saveState()\n page_w, page_h = doc.pagesize\n margin = doc.leftMargin\n dark_navy = self.brand.get('dark_navy', DARK_NAVY)\n accent_teal = self.brand.get('accent_teal', ACCENT_TEAL)\n\n # Top accent block\n canvas.setFillColor(dark_navy)\n canvas.rect(0, page_h - 1.2 * inch, page_w, 1.2 * inch,\n fill=1, stroke=0)\n canvas.setStrokeColor(accent_teal)\n canvas.setLineWidth(3)\n canvas.line(0, page_h - 1.2 * inch, page_w, page_h - 1.2 * inch)\n\n # Company name in header block\n canvas.setFillColor(WHITE)\n canvas.setFont('Helvetica-Bold', 14)\n canvas.drawString(margin, page_h - 0.55 * inch,\n self.company.upper() if self.company else '')\n canvas.setFont('Helvetica', 9)\n canvas.setFillColor(HexColor(\"#8899aa\"))\n canvas.drawString(margin, page_h - 0.8 * inch, self.doc_subtitle)\n\n # Bottom accent bar\n canvas.setFillColor(accent_teal)\n canvas.rect(0, 0.5 * inch, page_w, 3, fill=1, stroke=0)\n\n # Footer\n canvas.setFillColor(LIGHT_TEXT)\n canvas.setFont('Helvetica', 7)\n if self.classification:\n canvas.drawString(margin, 0.3 * inch,\n f\"{self.classification} | {self.company}\")\n canvas.drawRightString(page_w - margin, 0.3 * inch, self.doc_date)\n\n canvas.restoreState()\n\n def _draw_page(self, canvas, doc):\n \"\"\"Compact navy header bar, footer with classification and page.\"\"\"\n canvas.saveState()\n page_w, page_h = doc.pagesize\n margin = doc.leftMargin\n dark_navy = self.brand.get('dark_navy', DARK_NAVY)\n accent_teal = self.brand.get('accent_teal', ACCENT_TEAL)\n\n # Header bar\n canvas.setFillColor(dark_navy)\n canvas.rect(0, page_h - 0.45 * inch, page_w, 0.45 * inch,\n fill=1, stroke=0)\n canvas.setStrokeColor(accent_teal)\n canvas.setLineWidth(2)\n canvas.line(0, page_h - 0.45 * inch, page_w, page_h - 0.45 * inch)\n\n canvas.setFillColor(WHITE)\n canvas.setFont('Helvetica-Bold', 7.5)\n canvas.drawString(margin, page_h - 0.3 * inch,\n self.company.upper() if self.company else '')\n canvas.setFont('Helvetica', 7)\n canvas.setFillColor(HexColor(\"#8899aa\"))\n canvas.drawRightString(page_w - margin, page_h - 0.3 * inch,\n self.doc_subtitle)\n\n # Footer\n canvas.setStrokeColor(RULE_COLOR)\n canvas.setLineWidth(0.4)\n canvas.line(margin, 0.55 * inch, page_w - margin, 0.55 * inch)\n\n canvas.setFillColor(LIGHT_TEXT)\n canvas.setFont('Helvetica', 6.5)\n if self.classification:\n canvas.drawString(margin, 0.38 * inch, self.classification)\n canvas.drawCentredString(page_w / 2, 0.38 * inch, self.doc_date)\n canvas.drawRightString(page_w - margin, 0.38 * inch,\n f\"Page {doc.page}\")\n\n canvas.restoreState()\n\n\n# ======================================================================\n# XML escaping and inline formatting\n# ======================================================================\n\ndef _escape_xml(text: str) -> str:\n \"\"\"Escape special XML characters for ReportLab Paragraph markup.\"\"\"\n return (text.replace('&', '&').replace('\u003c', '<')\n .replace('>', '>').replace('\"', '"'))\n\n\ndef _runs_to_xml(runs: list[dict]) -> str:\n \"\"\"Convert inline runs from kidoc_md_parser to ReportLab XML markup.\"\"\"\n parts = []\n for r in runs:\n text = _escape_xml(r['text'])\n if r.get('code'):\n parts.append(\n f'\u003cfont face=\"Courier\" size=\"7.5\" color=\"#c0392b\">'\n f'{text}\u003c/font>')\n elif r.get('bold') and r.get('italic'):\n parts.append(f'\u003cb>\u003ci>{text}\u003c/i>\u003c/b>')\n elif r.get('bold'):\n parts.append(f'\u003cb>{text}\u003c/b>')\n elif r.get('italic'):\n parts.append(f'\u003ci>{text}\u003c/i>')\n elif r.get('link'):\n href = _escape_xml(r['link'])\n parts.append(\n f'\u003ca href=\"{href}\" color=\"#1b6ca8\">\u003cu>{text}\u003c/u>\u003c/a>')\n else:\n parts.append(text)\n return ''.join(parts)\n\n\ndef _strip_html_comments(text: str) -> str:\n \"\"\"Remove HTML comments (AUTO markers, NARRATIVE markers, etc.).\"\"\"\n return re.sub(r'\u003c!--.*?-->', '', text, flags=re.DOTALL)\n\n\n# ======================================================================\n# Cover page\n# ======================================================================\n\ndef build_cover(title: str, subtitle: str, meta_lines: list[tuple],\n styles: dict, classification: str = '',\n brand: dict | None = None) -> list:\n \"\"\"Build cover page flowables: title, accent rule, metadata table.\"\"\"\n b = brand or {}\n dark_navy = b.get('dark_navy', DARK_NAVY)\n accent_teal = b.get('accent_teal', ACCENT_TEAL)\n\n elements = []\n elements.append(Spacer(1, 1.6 * inch))\n\n # Title\n elements.append(Paragraph(\n _escape_xml(title).replace('\\n', '\u003cbr/>'),\n ParagraphStyle(\n 'CoverTitle', fontName='Helvetica-Bold', fontSize=34,\n leading=40, textColor=dark_navy,\n ),\n ))\n elements.append(Spacer(1, 6))\n\n # Accent rule (35% width, 3pt teal, left-aligned)\n elements.append(HRFlowable(\n width=\"35%\", thickness=3, color=accent_teal,\n spaceBefore=0, spaceAfter=16, hAlign='LEFT',\n ))\n\n # Subtitle\n elements.append(Paragraph(\n _escape_xml(subtitle),\n ParagraphStyle(\n 'CoverSub', fontName='Helvetica', fontSize=14, leading=18,\n textColor=MEDIUM_TEXT,\n ),\n ))\n elements.append(Spacer(1, 1.2 * inch))\n\n # Metadata table with subtle bottom borders\n if meta_lines:\n meta_data = []\n for label, value in meta_lines:\n meta_data.append([\n Paragraph(_escape_xml(label), styles['meta_label']),\n Paragraph(_escape_xml(value), styles['meta_value']),\n ])\n meta_table = Table(meta_data, colWidths=[1.5 * inch, 4 * inch])\n meta_table.setStyle(TableStyle([\n ('VALIGN', (0, 0), (-1, -1), 'TOP'),\n ('TOPPADDING', (0, 0), (-1, -1), 4),\n ('BOTTOMPADDING', (0, 0), (-1, -1), 4),\n ('LINEBELOW', (0, 0), (-1, -2), 0.3, RULE_COLOR),\n ]))\n elements.append(meta_table)\n\n elements.append(Spacer(1, 1 * inch))\n\n # Classification tag\n if classification:\n elements.append(Paragraph(\n f'\u003cfont color=\"#c0392b\">\u003cb>{_escape_xml(classification.upper())}\u003c/b>\u003c/font>',\n ParagraphStyle('ConfTag', fontSize=9, leading=12),\n ))\n\n return elements\n\n\n# ======================================================================\n# Table of Contents\n# ======================================================================\n\ndef build_toc(elements: list[dict], styles: dict,\n brand: dict | None = None) -> list:\n \"\"\"Build TOC from parsed markdown elements.\n\n Extracts headings from the element list and renders as a styled TOC.\n Only includes level-1 and level-2 headings (## and ###).\n Skips the first heading (document title, shown on cover).\n \"\"\"\n b = brand or {}\n dark_navy = b.get('dark_navy', DARK_NAVY)\n accent_teal = b.get('accent_teal', ACCENT_TEAL)\n\n flowables = []\n flowables.append(Paragraph(\"Table of Contents\", ParagraphStyle(\n 'TOCTitle', fontName='Helvetica-Bold', fontSize=18, leading=22,\n textColor=dark_navy, spaceAfter=12,\n )))\n flowables.append(HRFlowable(\n width=\"100%\", thickness=1, color=accent_teal,\n spaceBefore=0, spaceAfter=12,\n ))\n\n # Collect headings, skip the first H1 (title)\n first_h1_seen = False\n for elem in elements:\n if elem['type'] != 'heading':\n continue\n level = elem['level']\n if level == 1 and not first_h1_seen:\n first_h1_seen = True\n continue\n if level > 3:\n continue\n\n text = _escape_xml(elem['text'])\n if level \u003c= 2:\n flowables.append(Paragraph(\n f'\u003cb>{text}\u003c/b>', styles['toc'],\n ))\n else:\n flowables.append(Paragraph(text, styles['toc_sub']))\n\n flowables.append(PageBreak())\n return flowables\n\n\n# ======================================================================\n# Table builder\n# ======================================================================\n\ndef build_table(headers: list[str], rows: list[list[str]],\n styles: dict, content_w: float,\n brand: dict | None = None) -> list:\n \"\"\"Build a styled table: dark header, alternating rows, rounded corners.\n\n First column rendered bold. Thin borders with RULE_COLOR.\n \"\"\"\n if not headers:\n return []\n\n b = brand or {}\n table_header_bg = b.get('table_header_bg', TABLE_HEADER_BG)\n table_alt_row = b.get('table_alt_row', TABLE_ALT_ROW)\n accent_blue = b.get('accent_blue', ACCENT_BLUE)\n\n num_cols = len(headers)\n table_data = []\n\n # Header row\n header = [Paragraph(_escape_xml(h), styles['table_header'])\n for h in headers]\n table_data.append(header)\n\n # Data rows\n for row in rows:\n styled_row = []\n for j in range(num_cols):\n cell_text = row[j] if j \u003c len(row) else ''\n if j == 0:\n styled_row.append(\n Paragraph(_escape_xml(cell_text), styles['table_cell_bold']))\n else:\n styled_row.append(\n Paragraph(_escape_xml(cell_text), styles['table_cell']))\n table_data.append(styled_row)\n\n col_widths = [content_w / num_cols] * num_cols\n t = Table(table_data, colWidths=col_widths, repeatRows=1)\n\n cmds = [\n ('BACKGROUND', (0, 0), (-1, 0), table_header_bg),\n ('TEXTCOLOR', (0, 0), (-1, 0), WHITE),\n ('BOTTOMPADDING', (0, 0), (-1, 0), 7),\n ('TOPPADDING', (0, 0), (-1, 0), 7),\n ('LEFTPADDING', (0, 0), (-1, -1), 6),\n ('RIGHTPADDING', (0, 0), (-1, -1), 6),\n ('TOPPADDING', (0, 1), (-1, -1), 5),\n ('BOTTOMPADDING', (0, 1), (-1, -1), 5),\n ('GRID', (0, 0), (-1, -1), 0.4, HexColor(\"#dde4ea\")),\n ('LINEBELOW', (0, 0), (-1, 0), 1.5, accent_blue),\n ('VALIGN', (0, 0), (-1, -1), 'TOP'),\n ('ROUNDEDCORNERS', [3, 3, 3, 3]),\n ]\n\n # Alternating row colors\n for ri in range(1, len(table_data)):\n if ri % 2 == 0:\n cmds.append(('BACKGROUND', (0, ri), (-1, ri), table_alt_row))\n\n t.setStyle(TableStyle(cmds))\n return [t, Spacer(1, 8)]\n\n\n# ======================================================================\n# Element-to-flowable conversion\n# ======================================================================\n\ndef elements_to_flowables(elements: list[dict], styles: dict,\n base_dir: str, content_w: float,\n brand: dict | None = None) -> list:\n \"\"\"Convert parsed markdown elements to ReportLab flowables.\n\n Uses kidoc_md_parser element types. The first H1 heading is skipped\n (it appears on the cover page).\n \"\"\"\n b = brand or {}\n accent_blue = b.get('accent_blue', ACCENT_BLUE)\n accent_teal = b.get('accent_teal', ACCENT_TEAL)\n callout_bg = b.get('callout_bg', CALLOUT_BG)\n callout_border = b.get('callout_border', CALLOUT_BORDER)\n\n flowables = []\n first_h1_seen = False\n figure_counter = 0\n\n for elem in elements:\n etype = elem['type']\n\n # --- heading ---\n if etype == 'heading':\n level = elem['level']\n text = _escape_xml(elem['text'])\n\n if level == 1 and not first_h1_seen:\n first_h1_seen = True\n continue # skip title, it's on the cover\n\n if level == 1:\n flowables.append(Paragraph(text, styles['h1']))\n flowables.append(HRFlowable(\n width=\"100%\", thickness=1.5, color=accent_blue,\n spaceBefore=0, spaceAfter=6,\n ))\n elif level == 2:\n # KeepTogether: heading + rule stays with first paragraph\n heading_group = [\n Spacer(1, 6),\n Paragraph(text, styles['h1']),\n HRFlowable(\n width=\"100%\", thickness=1, color=accent_teal,\n spaceBefore=0, spaceAfter=4,\n ),\n ]\n flowables.append(KeepTogether(heading_group))\n elif level == 3:\n flowables.append(Spacer(1, 4))\n flowables.append(Paragraph(text, styles['h2']))\n else:\n flowables.append(Paragraph(text, styles['h3']))\n\n # --- paragraph ---\n elif etype == 'paragraph':\n xml = _runs_to_xml(elem['runs'])\n if xml.strip():\n flowables.append(Paragraph(xml, styles['body']))\n\n # --- image ---\n elif etype == 'image':\n figure_counter += 1\n img_flowables = _build_image(\n elem, base_dir, content_w, styles, figure_counter)\n flowables.extend(img_flowables)\n\n # --- table ---\n elif etype == 'table':\n flowables.extend(build_table(\n elem['headers'], elem['rows'], styles, content_w,\n brand=b))\n\n # --- code_block ---\n elif etype == 'code_block':\n code = _escape_xml(elem['code'])\n flowables.append(Preformatted(code, styles['code']))\n\n # --- bullet_list ---\n elif etype == 'bullet_list':\n for item_runs in elem['items']:\n xml = _runs_to_xml(item_runs)\n flowables.append(Paragraph(\n f'\u003cbullet>•\u003c/bullet> {xml}',\n styles['bullet'],\n ))\n\n # --- numbered_list ---\n elif etype == 'numbered_list':\n for i, item_runs in enumerate(elem['items'], 1):\n xml = _runs_to_xml(item_runs)\n flowables.append(Paragraph(\n f'\u003cbullet>{i}.\u003c/bullet> {xml}',\n styles['numbered'],\n ))\n\n # --- blockquote ---\n elif etype == 'blockquote':\n xml = _runs_to_xml(elem['runs'])\n flowables.append(Spacer(1, 4))\n flowables.append(CalloutBox(\n xml, content_w, callout_bg, callout_border,\n styles['callout'],\n ))\n flowables.append(Spacer(1, 4))\n\n # --- hr ---\n elif etype == 'hr':\n flowables.append(Spacer(1, 4))\n flowables.append(HRFlowable(\n width=\"100%\", thickness=0.5, color=RULE_COLOR,\n spaceBefore=2, spaceAfter=6,\n ))\n\n return flowables\n\n\ndef _build_image(elem: dict, base_dir: str, content_w: float,\n styles: dict, figure_num: int) -> list:\n \"\"\"Build image flowable with figure caption.\n\n Uses svg_to_flowable for vector SVG embedding. Falls back to\n placeholder text if file not found.\n\n Caps image height to fit within the page content area and wraps\n figure + caption in KeepTogether to prevent splitting across pages.\n Large full-sheet schematics (width > 400pt) get a PageBreak before them.\n \"\"\"\n MAX_IMAGE_HEIGHT = 500 # points — leaves room for header, footer, caption\n\n path = elem['path']\n if not os.path.isabs(path):\n path = os.path.join(base_dir, path)\n\n flowables = []\n\n if not os.path.isfile(path):\n flowables.append(Paragraph(\n f'\u003ci>[Image not found: {_escape_xml(elem[\"path\"])}]\u003c/i>',\n styles['figure_caption'],\n ))\n return flowables\n\n max_width = content_w\n img_flowable = None\n if path.lower().endswith('.svg'):\n img_flowable = svg_to_flowable(path, max_width)\n else:\n # Raster image\n try:\n from reportlab.platypus import Image\n img_flowable = Image(path)\n if img_flowable.drawWidth > max_width:\n scale = max_width / img_flowable.drawWidth\n img_flowable.drawWidth *= scale\n img_flowable.drawHeight *= scale\n except Exception:\n flowables.append(Paragraph(\n f'\u003ci>[Failed to load image: {_escape_xml(elem[\"path\"])}]\u003c/i>',\n styles['figure_caption'],\n ))\n return flowables\n\n if img_flowable is None:\n return flowables\n\n # Cap image height to fit within the page content frame\n img_h = getattr(img_flowable, 'height', 0) or getattr(img_flowable, 'drawHeight', 0)\n img_w = getattr(img_flowable, 'width', 0) or getattr(img_flowable, 'drawWidth', 0)\n if img_h > MAX_IMAGE_HEIGHT:\n ratio = MAX_IMAGE_HEIGHT / img_h\n if hasattr(img_flowable, 'height'):\n img_flowable.width = img_flowable.width * ratio\n img_flowable.height = MAX_IMAGE_HEIGHT\n if hasattr(img_flowable, 'drawHeight'):\n img_flowable.drawWidth = img_flowable.drawWidth * ratio\n img_flowable.drawHeight = MAX_IMAGE_HEIGHT\n if hasattr(img_flowable, 'renderScale'):\n img_flowable.renderScale = getattr(img_flowable, 'renderScale', 1.0) * ratio\n img_w = img_w * ratio\n\n # Full-sheet schematics get a page break before them\n if img_w > 400:\n flowables.append(PageBreak())\n\n # Build figure group: image + optional caption, kept together\n figure_elements = [img_flowable]\n\n # Figure caption (if alt text provided)\n alt = elem.get('alt', '').strip()\n if alt:\n figure_elements.append(Spacer(1, 4))\n figure_elements.append(Paragraph(\n f'\u003ci>Figure {figure_num}: {_escape_xml(alt)}\u003c/i>',\n styles['figure_caption'],\n ))\n\n flowables.append(KeepTogether(figure_elements))\n return flowables\n\n\n# ======================================================================\n# Main generation\n# ======================================================================\n\ndef generate_pdf(markdown_path: str, output_path: str, config: dict) -> str:\n \"\"\"Convert markdown to PDF with publication-quality styling.\n\n Returns the output path.\n \"\"\"\n with open(markdown_path, 'r', encoding='utf-8') as f:\n md_text = f.read()\n\n # Strip HTML comments before parsing\n md_text = _strip_html_comments(md_text)\n\n elements = parse_markdown(md_text)\n brand = _resolve_branding(config)\n styles = create_styles(brand=brand)\n base_dir = os.path.dirname(os.path.abspath(markdown_path))\n\n # Extract config\n project = config.get('project', {})\n reports = config.get('reports', {})\n\n title = project.get('name', 'Untitled Document')\n subtitle = reports.get('subtitle', project.get('number', ''))\n company = project.get('company', '')\n classification = reports.get('classification', '')\n author = project.get('author', '')\n revision = project.get('revision', '')\n doc_date = project.get('date', datetime.now().strftime(\"%Y-%m-%d\"))\n\n # If markdown starts with H1, use that as the title\n for elem in elements:\n if elem['type'] == 'heading' and elem['level'] == 1:\n title = elem['text']\n break\n\n # Page size\n page_size_name = reports.get('page_size', 'letter')\n page_size = PAGE_SIZES.get(page_size_name.lower(), letter)\n page_w = page_size[0]\n margin = inch\n content_w = page_w - 2 * margin\n\n # Build document\n os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.',\n exist_ok=True)\n\n doc = KidocDocTemplate(\n output_path,\n doc_title=title,\n doc_subtitle=subtitle or title,\n company=company,\n classification=classification,\n doc_date=doc_date,\n brand=brand,\n pagesize=page_size,\n topMargin=0.7 * inch,\n bottomMargin=0.7 * inch,\n leftMargin=margin,\n rightMargin=margin,\n title=title,\n author=author or company,\n subject=subtitle or title,\n )\n\n story = []\n\n # Cover page\n meta_lines = []\n if project.get('number'):\n meta_lines.append((\"Document\", project['number']))\n if revision:\n meta_lines.append((\"Revision\", revision))\n if author:\n meta_lines.append((\"Author\", author))\n if doc_date:\n meta_lines.append((\"Date\", doc_date))\n if classification:\n meta_lines.append((\"Classification\", classification))\n\n story.extend(build_cover(title, subtitle or title, meta_lines, styles,\n classification, brand=brand))\n story.append(PageBreak())\n\n # Switch to main template after cover\n story.append(NextPageTemplate('main'))\n\n # TOC (only if enough sections)\n heading_count = sum(\n 1 for e in elements\n if e['type'] == 'heading' and e['level'] \u003c= 2\n )\n if heading_count >= 4:\n story.extend(build_toc(elements, styles, brand=brand))\n\n # Content\n story.extend(elements_to_flowables(elements, styles, base_dir, content_w,\n brand=brand))\n\n doc.build(story)\n return output_path\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Generate publication-quality PDF from markdown')\n parser.add_argument('--input', '-i', required=True,\n help='Input markdown file')\n parser.add_argument('--output', '-o', required=True,\n help='Output PDF file')\n parser.add_argument('--config', '-c', default='{}',\n help='JSON config string or path to config file')\n args = parser.parse_args()\n\n # Load config\n if os.path.isfile(args.config):\n with open(args.config) as f:\n config = json.load(f)\n else:\n config = json.loads(args.config)\n\n output = generate_pdf(args.input, args.output, config)\n print(output, file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":31290,"content_sha256":"6a7ca6ba13ea944ac4328f168533b114d1a3a5649ccdc38310b079db334ccbf6"},{"filename":"scripts/kidoc_raster.py","content":"\"\"\"Shared SVG-to-PNG rasterization utility.\n\nUsed by kidoc_docx.py and kidoc_odt.py for embedding figures in\ndocument formats that don't support inline SVG. Wraps the Pillow-based\nrasterizer from ``figures.lib.svg_to_png``.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport tempfile\n\ntry:\n from figures.lib.svg_to_png import svg_to_png as _svg_to_png_impl\n _HAS_SVG_RENDER = True\nexcept ImportError:\n _HAS_SVG_RENDER = False\n\n\ndef has_svg_render() -> bool:\n \"\"\"Check whether SVG rasterization is available.\"\"\"\n return _HAS_SVG_RENDER\n\n\ndef svg_to_png(svg_path: str, dpi: int = 300) -> str | None:\n \"\"\"Convert SVG to a temporary PNG file. Returns PNG path or None.\"\"\"\n if not _HAS_SVG_RENDER or not os.path.isfile(svg_path):\n return None\n try:\n fd, png_path = tempfile.mkstemp(suffix='.png')\n os.close(fd)\n _svg_to_png_impl(svg_path, png_path, dpi=dpi)\n return png_path\n except Exception:\n return None\n\n\ndef get_dpi(config: dict) -> int:\n \"\"\"Extract schematic rendering DPI from config.\"\"\"\n return config.get('reports', {}).get('rendering', {}).get('schematic_dpi', 300)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":1155,"content_sha256":"c9d3405150de70e0fdb3aa44ec2f1e10ee310d22da21b0024dfacd10476ed66f"},{"filename":"scripts/kidoc_scaffold.py","content":"#!/usr/bin/env python3\n\"\"\"Markdown scaffold generator for engineering documentation.\n\nReads analysis JSONs and .kicad-happy.json config to produce a structured\nmarkdown document with `\u003c!-- GENERATED: section_id -->` markers for\nregeneratable content and narrative placeholders for Claude/user prose.\n\nUsage:\n python3 kidoc_scaffold.py --project-dir . --type hdd --output reports/HDD.md\n python3 kidoc_scaffold.py --project-dir . --type design_review --output reports/DR.md\n python3 kidoc_scaffold.py --project-dir . --config .kicad-happy.json --output reports/\n\nExplicit analyzer JSON inputs (bypass the analysis cache lookup — useful\nfor harness batch runs and any caller that already has analyzer outputs\non disk but not organised under an analysis/manifest.json convention):\n\n python3 kidoc_scaffold.py --schematic-json sch.json --pcb-json pcb.json \\\\\n --emc-json emc.json --thermal-json thermal.json --type hdd \\\\\n --output reports/HDD.md\n\nZero external dependencies — Python 3.8+ stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n_kicad_scripts = os.path.join(os.path.dirname(os.path.abspath(__file__)),\n '..', '..', 'kicad', 'scripts')\nif os.path.isdir(_kicad_scripts):\n sys.path.insert(0, os.path.abspath(_kicad_scripts))\n\nfrom kidoc_sections import (\n section_front_matter, section_executive_summary,\n section_system_overview, section_power_design,\n section_signal_interfaces, section_analog_design, section_thermal,\n section_emc, section_pcb_design, section_bom_summary,\n section_test_debug, section_compliance, section_appendix_schematics,\n section_mechanical_environmental,\n # CE Technical File\n section_ce_product_identification, section_ce_essential_requirements,\n section_ce_harmonized_standards, section_ce_risk_assessment,\n section_ce_declaration_of_conformity,\n # Design Review\n section_review_summary, section_review_action_items,\n # ICD\n section_icd_interface_list, section_icd_connector_details,\n section_icd_electrical_characteristics,\n # Manufacturing\n section_mfg_assembly_overview, section_mfg_pcb_fab_notes,\n section_mfg_assembly_instructions, section_mfg_test_procedures,\n)\nfrom kidoc_templates import get_section_list, get_document_title\n\n# Try to import project_config for cascading config loading\ntry:\n from project_config import load_config, load_config_from_path\nexcept ImportError:\n def load_config(search_dir):\n return {'version': 1, 'project': {}, 'suppressions': []}\n def load_config_from_path(path):\n return {'version': 1, 'project': {}, 'suppressions': []}\n\n\n# ======================================================================\n# Analysis cache loading\n# ======================================================================\n\ndef load_analysis_cache(project_dir: str,\n cache_dir: str | None = None) -> dict:\n \"\"\"Load all analysis JSONs from the analysis/ manifest.\n\n Uses the manifest.json in the analysis directory to find the current\n run folder and load all output JSONs from it.\n\n Falls back to direct file search in cache_dir or project_dir if\n no manifest exists (for backwards compat during transition).\n\n Args:\n project_dir: Root directory of the KiCad project.\n cache_dir: Override analysis directory path.\n\n Returns dict with keys: schematic, pcb, emc, thermal, spice, gate.\n \"\"\"\n cache = {}\n\n # Determine analysis directory\n analysis_dir = cache_dir\n if not analysis_dir:\n # Read output_dir from config\n try:\n from project_config import load_config\n config = load_config(project_dir)\n analysis_dir = os.path.join(\n project_dir,\n config.get('analysis', {}).get('output_dir', 'analysis'))\n except ImportError:\n analysis_dir = os.path.join(project_dir, 'analysis')\n\n # Try manifest-based loading\n manifest_path = os.path.join(analysis_dir, 'manifest.json')\n if os.path.isfile(manifest_path):\n try:\n with open(manifest_path, 'r', encoding='utf-8') as f:\n manifest = json.load(f)\n current_id = manifest.get('current')\n if current_id and current_id in manifest.get('runs', {}):\n run_dir = os.path.join(analysis_dir, current_id)\n run_meta = manifest['runs'][current_id]\n for analysis_type, filename in run_meta.get('outputs', {}).items():\n filepath = os.path.join(run_dir, filename)\n if os.path.isfile(filepath):\n try:\n with open(filepath, 'r', encoding='utf-8') as f:\n cache[analysis_type] = json.load(f)\n except json.JSONDecodeError:\n pass\n return cache\n except (json.JSONDecodeError, OSError):\n pass # Fall through to empty return\n\n # No manifest -- return empty cache\n return cache\n\n\ndef _load_explicit_jsons(paths: dict) -> dict:\n \"\"\"Load analyzer JSONs from explicit CLI-provided paths.\n\n Bypasses the manifest-based analysis cache lookup entirely. Used\n when a caller (e.g., a batch harness runner) already has analyzer\n outputs on disk and doesn't want kidoc to run its own discovery.\n\n Args:\n paths: dict mapping analysis type key ('schematic', 'pcb', 'emc',\n 'thermal') to file path, or None for unprovided types.\n\n Returns:\n dict mapping analysis type to loaded JSON dict, for paths that\n were provided and loaded successfully. Types with a None path\n are skipped. Types with an unreadable or malformed file cause\n an error and sys.exit(1) — this function assumes explicit paths\n are authoritative and should fail loudly rather than silently\n skip.\n \"\"\"\n cache = {}\n for analysis_type, path in paths.items():\n if path is None:\n continue\n if not os.path.isfile(path):\n print(f\"Error: {analysis_type} JSON not found: {path}\",\n file=sys.stderr)\n sys.exit(1)\n try:\n with open(path, 'r', encoding='utf-8') as f:\n data = json.load(f)\n except (json.JSONDecodeError, OSError) as e:\n print(f\"Error: cannot load {analysis_type} JSON {path}: {e}\",\n file=sys.stderr)\n sys.exit(1)\n if ('signal_analysis' in data and 'findings' not in data\n and analysis_type == 'schematic'):\n print(f\"Error: {path} uses the pre-v1.3 signal_analysis \"\n f\"wrapper format.\\nRe-run analyze_schematic.py to \"\n f\"produce the current findings[] format.\",\n file=sys.stderr)\n sys.exit(1)\n cache[analysis_type] = data\n return cache\n\n\n# ======================================================================\n# Template variable resolution\n# ======================================================================\n\ndef resolve_template_vars(text: str, config: dict) -> str:\n \"\"\"Replace {project}, {rev}, etc. placeholders.\"\"\"\n project = config.get('project', {})\n replacements = {\n '{project}': project.get('name', ''),\n '{rev}': project.get('revision', ''),\n '{company}': project.get('company', ''),\n '{number}': project.get('number', ''),\n '{classification}': config.get('reports', {}).get('classification', ''),\n '{author}': project.get('author', ''),\n }\n for key, val in replacements.items():\n text = text.replace(key, val)\n return text\n\n\n# ======================================================================\n# Scaffold generation\n# ======================================================================\n\ndef scaffold_document(project_dir: str, doc_type: str, output_path: str,\n config: dict,\n analysis_cache: dict | None = None,\n analysis_dir: str | None = None,\n spec: dict | None = None) -> str:\n \"\"\"Generate a markdown scaffold for the specified document type.\n\n Returns the markdown content (also writes to output_path).\n \"\"\"\n if analysis_cache is None:\n analysis_cache = load_analysis_cache(project_dir)\n\n analysis = analysis_cache.get('schematic', {})\n pcb_data = analysis_cache.get('pcb')\n emc_data = analysis_cache.get('emc')\n thermal_data = analysis_cache.get('thermal')\n\n # Determine paths for diagrams and schematic SVGs.\n # Figures live under reports/figures/ (git-tracked), separate from\n # analysis/ (gitignored, managed by analysis_cache.py) which holds JSON data.\n output_abs = os.path.abspath(output_path)\n reports_root = os.path.dirname(output_abs)\n figures_base = os.path.join(reports_root, 'figures')\n diagrams_dir = os.path.join(figures_base, 'diagrams')\n sch_cache_dir = os.path.join(figures_base, 'schematics')\n\n # Use relative paths from the output file's directory\n output_dir = os.path.dirname(os.path.abspath(output_path))\n try:\n diagrams_rel = os.path.relpath(diagrams_dir, output_dir)\n sch_cache_rel = os.path.relpath(sch_cache_dir, output_dir)\n except ValueError:\n diagrams_rel = diagrams_dir\n sch_cache_rel = sch_cache_dir\n\n # Get sections for this document type (spec overrides config overrides)\n if spec:\n from kidoc_spec import get_section_types\n sections = get_section_types(spec)\n else:\n sections = get_section_list(doc_type, config)\n\n gate_data = analysis_cache.get('gate')\n\n # Build markdown\n parts = []\n\n section_map = {\n # Core sections (HDD)\n 'front_matter': lambda: section_front_matter(config, doc_type),\n 'executive_summary': lambda: section_executive_summary(analysis, emc_data, thermal_data, pcb_data),\n 'system_overview': lambda: section_system_overview(analysis, diagrams_rel),\n 'power_design': lambda: section_power_design(analysis, diagrams_rel),\n 'signal_interfaces': lambda: section_signal_interfaces(analysis),\n 'analog_design': lambda: section_analog_design(analysis, diagrams_rel),\n 'thermal_analysis': lambda: section_thermal(thermal_data),\n 'emc_analysis': lambda: section_emc(emc_data),\n 'pcb_design': lambda: section_pcb_design(pcb_data),\n 'mechanical_environmental': lambda: section_mechanical_environmental(analysis, pcb_data),\n 'bom_summary': lambda: section_bom_summary(analysis),\n 'test_debug': lambda: section_test_debug(analysis),\n 'compliance': lambda: section_compliance(analysis, emc_data, config),\n 'appendix_schematics': lambda: section_appendix_schematics(sch_cache_rel, analysis, sch_cache_dir),\n # CE Technical File\n 'ce_product_identification': lambda: section_ce_product_identification(analysis, config),\n 'ce_essential_requirements': lambda: section_ce_essential_requirements(analysis, config),\n 'ce_harmonized_standards': lambda: section_ce_harmonized_standards(config),\n 'ce_risk_assessment': lambda: section_ce_risk_assessment(analysis, emc_data, thermal_data),\n 'ce_declaration_of_conformity': lambda: section_ce_declaration_of_conformity(config),\n # Design Review\n 'review_summary': lambda: section_review_summary(analysis, emc_data, thermal_data, gate_data),\n 'review_action_items': lambda: section_review_action_items(config),\n # ICD\n 'icd_interface_list': lambda: section_icd_interface_list(analysis),\n 'icd_connector_details': lambda: section_icd_connector_details(analysis, config),\n 'icd_electrical_characteristics': lambda: section_icd_electrical_characteristics(analysis),\n # Manufacturing\n 'mfg_assembly_overview': lambda: section_mfg_assembly_overview(analysis),\n 'mfg_pcb_fab_notes': lambda: section_mfg_pcb_fab_notes(pcb_data),\n 'mfg_assembly_instructions': lambda: section_mfg_assembly_instructions(analysis),\n 'mfg_test_procedures': lambda: section_mfg_test_procedures(analysis),\n }\n\n for section_name in sections:\n generator = section_map.get(section_name)\n if generator:\n content = generator()\n if content is not None:\n parts.append(content)\n\n markdown = \"\\n\".join(parts)\n\n # Resolve template variables\n markdown = resolve_template_vars(markdown, config)\n\n # Write output (overwrites — use git to track/merge user edits)\n os.makedirs(os.path.dirname(os.path.abspath(output_path)) or '.', exist_ok=True)\n with open(output_path, 'w', encoding='utf-8') as f:\n f.write(markdown)\n\n return markdown\n\n\n# ======================================================================\n# Auto-run analyses\n# ======================================================================\n\ndef _auto_run_analyses(project_dir: str, analysis_dir: str,\n figures_dir: str | None = None,\n sch_path: str | None = None,\n pcb_path: str | None = None) -> dict[str, bool]:\n \"\"\"Auto-run available analyses that haven't been generated yet.\n\n Args:\n figures_dir: Base directory for generated figures (diagrams, schematics).\n Defaults to ``analysis_dir`` parent's ``figures/`` sibling when None.\n\n Returns dict of {analysis_name: was_run_successfully} for reporting.\n \"\"\"\n if figures_dir is None:\n # Default: reports/figures/ (sibling of analysis output)\n figures_dir = os.path.join(os.path.dirname(os.path.normpath(analysis_dir)),\n '..', 'figures')\n results = {}\n scripts_dir = os.path.normpath(os.path.join(\n os.path.dirname(os.path.abspath(__file__)),\n '..', '..', 'kicad', 'scripts'))\n\n os.makedirs(analysis_dir, exist_ok=True)\n\n # Auto-detect schematic and PCB files if not specified\n if not sch_path:\n for f in Path(project_dir).rglob('*.kicad_sch'):\n sch_path = str(f)\n break\n if not pcb_path:\n for f in Path(project_dir).rglob('*.kicad_pcb'):\n pcb_path = str(f)\n break\n\n def _run_analysis(name: str, cmd: list[str]) -> None:\n \"\"\"Run an analysis subprocess with timeout, recording result.\"\"\"\n try:\n result = subprocess.run(\n cmd, capture_output=True, text=True, timeout=120)\n results[name] = result.returncode == 0\n except subprocess.TimeoutExpired:\n results[name] = False\n\n # Schematic analysis\n sch_json = os.path.join(analysis_dir, 'schematic.json')\n if sch_path and not os.path.isfile(sch_json):\n analyzer = os.path.join(scripts_dir, 'analyze_schematic.py')\n if os.path.isfile(analyzer):\n _run_analysis('schematic',\n [sys.executable, analyzer, sch_path,\n '--output', sch_json])\n\n # PCB analysis\n pcb_json = os.path.join(analysis_dir, 'pcb.json')\n if pcb_path and not os.path.isfile(pcb_json):\n analyzer = os.path.join(scripts_dir, 'analyze_pcb.py')\n if os.path.isfile(analyzer):\n _run_analysis('pcb',\n [sys.executable, analyzer, pcb_path,\n '--output', pcb_json])\n\n # EMC analysis (requires both schematic + PCB JSONs)\n emc_json = os.path.join(analysis_dir, 'emc.json')\n if (os.path.isfile(sch_json) and os.path.isfile(pcb_json)\n and not os.path.isfile(emc_json)):\n emc_scripts = os.path.normpath(os.path.join(\n os.path.dirname(os.path.abspath(__file__)),\n '..', '..', 'emc', 'scripts'))\n analyzer = os.path.join(emc_scripts, 'analyze_emc.py')\n if os.path.isfile(analyzer):\n _run_analysis('emc',\n [sys.executable, analyzer,\n '--schematic', sch_json, '--pcb', pcb_json,\n '--output', emc_json])\n\n # Thermal analysis (requires both schematic + PCB JSONs)\n thermal_json = os.path.join(analysis_dir, 'thermal.json')\n if (os.path.isfile(sch_json) and os.path.isfile(pcb_json)\n and not os.path.isfile(thermal_json)):\n analyzer = os.path.join(scripts_dir, 'analyze_thermal.py')\n if os.path.isfile(analyzer):\n _run_analysis('thermal',\n [sys.executable, analyzer,\n '--schematic', sch_json, '--pcb', pcb_json,\n '--output', thermal_json])\n\n # Figures (diagrams + charts from schematic analysis JSON)\n # Run via venv so matplotlib generators can render\n diagrams_dir = os.path.join(os.path.normpath(figures_dir), 'diagrams')\n if os.path.isfile(sch_json):\n try:\n from kidoc_venv import ensure_venv\n venv_py = ensure_venv(project_dir)\n except Exception as exc:\n print(f\" Warning: venv setup failed ({exc}), \"\n f\"matplotlib figures will be skipped\",\n file=sys.stderr)\n venv_py = sys.executable\n\n diagrams_script = os.path.join(\n os.path.dirname(os.path.abspath(__file__)), 'kidoc_diagrams.py')\n cmd = [venv_py, diagrams_script,\n '--analysis', sch_json,\n '--output', diagrams_dir]\n if os.path.isfile(emc_json):\n cmd.extend(['--emc', emc_json])\n if os.path.isfile(thermal_json):\n cmd.extend(['--thermal', thermal_json])\n spice_json = os.path.join(analysis_dir, 'spice.json')\n if os.path.isfile(spice_json):\n cmd.extend(['--spice', spice_json])\n _run_analysis('diagrams', cmd)\n\n # Schematic SVG renders (requires .kicad_sch)\n sch_cache_dir = os.path.join(os.path.normpath(figures_dir), 'schematics')\n if sch_path and not os.path.isdir(sch_cache_dir):\n try:\n from figures.renderers import render_schematic\n os.makedirs(sch_cache_dir, exist_ok=True)\n paths = render_schematic(sch_path, sch_cache_dir)\n results['renders'] = bool(paths)\n except (OSError, ValueError) as exc:\n print(f\" Warning: schematic render failed: {exc}\",\n file=sys.stderr)\n results['renders'] = False\n\n return results\n\n\ndef _print_analysis_summary(results: dict, analysis_dir: str,\n figures_dir: str | None = None) -> None:\n \"\"\"Print what analyses are available and what's missing.\"\"\"\n available = []\n missing = []\n\n checks = {\n 'schematic': 'schematic.json',\n 'pcb': 'pcb.json',\n 'emc': 'emc.json',\n 'thermal': 'thermal.json',\n 'spice': 'spice.json',\n }\n\n for name, filename in checks.items():\n path = os.path.join(analysis_dir, filename)\n if os.path.isfile(path):\n if name in results:\n available.append(f\" {name}: auto-generated\")\n else:\n available.append(f\" {name}: found\")\n else:\n if name in results:\n missing.append(f\" {name}: auto-run failed\")\n elif name == 'spice':\n missing.append(f\" {name}: requires manual SPICE simulation\")\n else:\n missing.append(f\" {name}: not available (no source data)\")\n\n # Check diagrams and renders (under figures/ directory)\n fig_base = figures_dir or os.path.join(\n os.path.dirname(os.path.normpath(analysis_dir)), '..', 'figures')\n diagrams_dir = os.path.join(os.path.normpath(fig_base), 'diagrams')\n if os.path.isdir(diagrams_dir):\n if 'diagrams' in results:\n available.append(\" diagrams: auto-generated\")\n else:\n available.append(\" diagrams: found\")\n else:\n missing.append(\" diagrams: not generated\")\n\n sch_fig_dir = os.path.join(os.path.normpath(fig_base), 'schematics')\n if os.path.isdir(sch_fig_dir):\n if 'renders' in results:\n available.append(\" renders: auto-generated\")\n else:\n available.append(\" renders: found\")\n else:\n missing.append(\" renders: not generated (needs kicad-cli)\")\n\n if available:\n print(\"Analysis data:\", file=sys.stderr)\n for a in available:\n print(a, file=sys.stderr)\n if missing:\n print(\"Not included (run separately to add):\", file=sys.stderr)\n for m in missing:\n print(m, file=sys.stderr)\n\n\n# ======================================================================\n# Main\n# ======================================================================\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Generate markdown scaffold for engineering documentation')\n parser.add_argument('--project-dir', '-p', default='.',\n help='Path to KiCad project directory')\n parser.add_argument('--type', '-t', default='hdd',\n choices=['hdd', 'ce_technical_file', 'design_review',\n 'icd', 'manufacturing',\n 'schematic_review', 'power_analysis',\n 'emc_report'],\n help='Document type (default: hdd)')\n parser.add_argument('--spec', default=None,\n help='Path to document spec JSON (overrides --type)')\n parser.add_argument('--output', '-o', required=True,\n help='Output markdown file path')\n parser.add_argument('--config', default=None,\n help='Path to .kicad-happy.json config')\n parser.add_argument('--analysis-dir', default=None,\n help='Directory containing analysis JSONs')\n parser.add_argument('--analyze', action='store_true',\n help='Run KiCad analysis scripts if no analysis data '\n 'exists. Without this flag, errors when analysis '\n 'is missing.')\n parser.add_argument('--schematic-json', default=None,\n help='Explicit path to schematic analyzer JSON. '\n 'When any of --schematic-json/--pcb-json/'\n '--emc-json/--thermal-json is provided, the '\n 'manifest-based analysis cache lookup is '\n 'bypassed entirely.')\n parser.add_argument('--pcb-json', default=None,\n help='Explicit path to PCB analyzer JSON. See '\n '--schematic-json for mode notes.')\n parser.add_argument('--emc-json', default=None,\n help='Explicit path to EMC analyzer JSON. See '\n '--schematic-json for mode notes.')\n parser.add_argument('--thermal-json', default=None,\n help='Explicit path to thermal analyzer JSON. See '\n '--schematic-json for mode notes.')\n args = parser.parse_args()\n\n # Load spec (--spec overrides --type)\n if args.spec:\n from kidoc_spec import load_spec\n spec = load_spec(args.spec)\n doc_type = spec.get('type', 'custom')\n else:\n from kidoc_spec import load_builtin_spec\n spec = load_builtin_spec(args.type)\n doc_type = args.type\n\n # Load config\n if args.config:\n config = load_config_from_path(args.config)\n else:\n config = load_config(args.project_dir)\n\n # Determine analysis directory from config or CLI\n if args.analysis_dir:\n analysis_dir = args.analysis_dir\n else:\n analysis_dir = os.path.join(\n args.project_dir,\n config.get('analysis', {}).get('output_dir', 'analysis'))\n\n # Figures (diagrams, schematics) go under reports/figures/ (git-tracked),\n # separate from analysis/ which holds analysis JSONs.\n output_dir = os.path.dirname(os.path.abspath(args.output))\n figures_dir = os.path.join(output_dir, 'figures')\n\n # Explicit JSON inputs bypass the manifest-based cache entirely.\n # If any of --schematic-json/--pcb-json/--emc-json/--thermal-json\n # is provided, _load_explicit_jsons is authoritative and we skip\n # both the cache lookup and --analyze auto-run.\n explicit_jsons = {\n 'schematic': args.schematic_json,\n 'pcb': args.pcb_json,\n 'emc': args.emc_json,\n 'thermal': args.thermal_json,\n }\n has_explicit = any(p is not None for p in explicit_jsons.values())\n\n if has_explicit:\n if args.analyze:\n print('Error: --analyze cannot be combined with explicit '\n '--schematic-json/--pcb-json/--emc-json/--thermal-json '\n 'flags. Explicit JSONs bypass the analysis pipeline; '\n 'drop --analyze or drop the explicit flags.',\n file=sys.stderr)\n sys.exit(1)\n cache = _load_explicit_jsons(explicit_jsons)\n else:\n # Load analysis cache from manifest\n cache = load_analysis_cache(args.project_dir, analysis_dir)\n\n if not cache:\n if args.analyze:\n # Run analyses using existing _auto_run_analyses\n auto_results = _auto_run_analyses(args.project_dir, analysis_dir,\n figures_dir=figures_dir)\n cache = load_analysis_cache(args.project_dir, analysis_dir)\n _print_analysis_summary(auto_results, analysis_dir,\n figures_dir=figures_dir)\n if not cache:\n print('Error: Analysis scripts ran but produced no output.',\n file=sys.stderr)\n sys.exit(1)\n else:\n print(f'Error: No analysis data found in '\n f'{analysis_dir}.\\n'\n 'Run with --analyze to generate it, or run the '\n 'KiCad analysis skill first.',\n file=sys.stderr)\n sys.exit(1)\n\n # Generate scaffold\n scaffold_document(\n project_dir=args.project_dir,\n doc_type=doc_type,\n output_path=args.output,\n config=config,\n analysis_cache=cache,\n analysis_dir=analysis_dir,\n spec=spec,\n )\n\n print(args.output, file=sys.stderr)\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":26627,"content_sha256":"1c538f9394ac8c5e08a159b0cffd2c1920ce1fcc014a52827f77f1939033da8b"},{"filename":"scripts/kidoc_sections.py","content":"\"\"\"Section content generators for the markdown scaffold.\n\nEach function generates clean markdown directly from analysis data.\nNo template markers in the output — the generated markdown is the final\ndocument. On regeneration, the scaffold is re-generated and the user\nreconciles with their edits via git diff/merge.\n\nNarrative prompts are italic placeholder text that the user or the agent\nreplaces with real prose.\n\nZero external dependencies — Python stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom kidoc_tables import (\n markdown_table, format_voltage, format_frequency,\n format_current, format_capacitance, format_resistance,\n)\nfrom finding_schema import Det, group_findings\n\n\ndef _auto(section_id: str, content: str) -> str:\n \"\"\"Emit auto-generated content directly. No markers needed.\"\"\"\n return content\n\n\ndef _narrative(section_id: str, hint: str = \"\") -> str:\n \"\"\"Generate a narrative prompt placeholder.\n\n Italic text that the user or Claude replaces with real prose.\n \"\"\"\n return f\"*[{hint or 'Describe the design decisions and rationale for this section.'}]*\"\n\n\n# ======================================================================\n# Front matter\n# ======================================================================\n\ndef section_front_matter(config: dict, doc_type: str) -> str:\n \"\"\"Generate title page and revision history.\"\"\"\n project = config.get('project', {})\n name = project.get('name', 'Untitled Project')\n number = project.get('number', '')\n revision = project.get('revision', '')\n company = project.get('company', '')\n author = project.get('author', '')\n\n doc_titles = {\n 'hdd': 'Hardware Design Description',\n 'ce_technical_file': 'CE Technical File',\n 'design_review': 'Design Review Package',\n 'icd': 'Interface Control Document',\n 'manufacturing': 'Manufacturing Transfer Package',\n }\n doc_title = doc_titles.get(doc_type, 'Engineering Document')\n\n lines = [f\"# {doc_title}\"]\n lines.append(\"\")\n lines.append(_auto(\"front_matter_info\", \"\\n\".join(filter(None, [\n f\"**Project:** {name}\" if name else None,\n f\"**Document Number:** {number}\" if number else None,\n f\"**Revision:** {revision}\" if revision else None,\n f\"**Company:** {company}\" if company else None,\n f\"**Author:** {author}\" if author else None,\n ]))))\n lines.append(\"\")\n\n # Revision history\n rev_history = config.get('reports', {}).get('revision_history', [])\n if rev_history:\n rows = [[r.get('rev', ''), r.get('date', ''), r.get('author', ''),\n r.get('description', '')] for r in rev_history]\n lines.append(\"## Revision History\")\n lines.append(\"\")\n lines.append(_auto(\"revision_history\",\n markdown_table(['Rev', 'Date', 'Author', 'Description'], rows)))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Executive summary\n# ======================================================================\n\ndef section_executive_summary(analysis: dict, emc_data: dict | None,\n thermal_data: dict | None,\n pcb_data: dict | None) -> str:\n \"\"\"Auto-generated one-paragraph project overview.\"\"\"\n lines = [\"## Executive Summary\"]\n lines.append(\"\")\n\n stats = analysis.get('statistics', {})\n total = stats.get('total_components', 0)\n unique = stats.get('unique_parts', 0)\n nets = stats.get('total_nets', 0)\n sheets = stats.get('sheets', 1)\n\n # Identify key ICs\n _sa = group_findings(analysis)\n regulators = _sa.get(Det.POWER_REGULATORS, [])\n mcus = [c for c in analysis.get('components', [])\n if c.get('type') == 'ic' and any(k in c.get('lib_id', '').lower()\n for k in ('mcu', 'stm32', 'esp32', 'rp2040', 'atmega', 'nrf',\n 'samd', 'wroom', 'wrover', 'microcontroller'))]\n\n parts = []\n parts.append(f\"This design contains **{total} components** ({unique} unique parts) \"\n f\"across **{nets} nets**\"\n + (f\" on {sheets} schematic sheets\" if sheets > 1 else \"\")\n + \".\")\n\n if mcus:\n mcu_list = ', '.join(c.get('value', c.get('reference', '?')) for c in mcus[:3])\n parts.append(f\"The primary processor is **{mcu_list}**.\")\n\n if regulators:\n rails = [f\"{r.get('output_rail', '?')} ({r.get('estimated_vout', '?')}V)\"\n for r in regulators if r.get('estimated_vout')]\n if rails:\n parts.append(f\"Power rails: {', '.join(rails)}.\")\n\n # PCB info\n if pcb_data:\n pcb_stats = pcb_data.get('statistics', {})\n layers = pcb_stats.get('copper_layers', '')\n outline = pcb_data.get('board_outline', {})\n dims = ''\n if outline:\n w = outline.get('width_mm')\n h = outline.get('height_mm')\n if w and h:\n dims = f\" ({w}×{h}mm)\"\n if layers:\n parts.append(f\"{layers}-layer PCB{dims}, \"\n f\"{pcb_stats.get('routing_completion', '?')}% routed.\")\n\n # EMC summary\n if emc_data:\n emc_sum = emc_data.get('summary', {})\n score = emc_sum.get('emc_risk_score')\n if score is not None:\n parts.append(f\"EMC risk score: {score}/100.\")\n\n lines.append(' '.join(parts))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# System overview\n# ======================================================================\n\ndef section_system_overview(analysis: dict, diagrams_dir: str) -> str:\n \"\"\"Generate system overview section.\"\"\"\n lines = [\"## 2. System Overview\"]\n lines.append(\"\")\n\n # Architecture diagram\n lines.append(f\"![System Architecture]({diagrams_dir}/architecture.svg)\")\n lines.append(\"\")\n lines.append(_narrative(\"system_overview_description\",\n \"Describe the system's purpose, key functions, and \"\n \"high-level architecture. Reference the block diagram above.\"))\n lines.append(\"\")\n\n # Statistics table — include PCB data if available from analysis\n stats = analysis.get('statistics', {})\n if stats:\n rows = [\n ['Total components', str(stats.get('total_components', 0))],\n ['Unique parts', str(stats.get('unique_parts', 0))],\n ['Nets', str(stats.get('total_nets', 0))],\n ['Schematic sheets', str(stats.get('sheets', 1))],\n ]\n # Add SMD/THT if available\n smd = stats.get('smd_count')\n tht = stats.get('tht_count')\n if smd is not None or tht is not None:\n rows.append(['SMD / THT', f\"{smd or 0} / {tht or 0}\"])\n # Add DNP if any\n dnp = stats.get('dnp_count', 0)\n if dnp:\n rows.append(['Do Not Populate', str(dnp)])\n # Missing MPNs\n missing = stats.get('missing_mpns', 0)\n if missing:\n rows.append(['Missing MPNs', str(missing)])\n lines.append(markdown_table(['Metric', 'Value'], rows))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Power design\n# ======================================================================\n\ndef section_power_design(analysis: dict, diagrams_dir: str) -> str:\n \"\"\"Generate power system design section.\"\"\"\n lines = [\"## 3. Power System Design\"]\n lines.append(\"\")\n\n # Power tree diagram\n lines.append(_auto(\"power_tree_diagram\",\n f\"![Power Tree]({diagrams_dir}/power_tree.svg)\"))\n lines.append(\"\")\n lines.append(_narrative(\"power_design_rationale\",\n \"Describe the power architecture: input voltage range, \"\n \"why this topology was chosen, efficiency targets, thermal constraints.\"))\n lines.append(\"\")\n\n # Power regulators table\n _sa = group_findings(analysis)\n regulators = _sa.get(Det.POWER_REGULATORS, [])\n if regulators:\n rows = []\n for reg in regulators:\n vout = reg.get('estimated_vout') or reg.get('output_voltage')\n rows.append([\n reg.get('ref', '?'),\n reg.get('value', ''),\n reg.get('topology', ''),\n reg.get('input_rail', '?'),\n reg.get('output_rail', '?'),\n format_voltage(vout),\n ])\n lines.append(_auto(\"power_rail_table\",\n markdown_table(\n ['Ref', 'Part', 'Topology', 'Input Rail', 'Output Rail', 'Vout'],\n rows)))\n lines.append(\"\")\n\n # Decoupling analysis\n decoupling = _sa.get(Det.DECOUPLING, [])\n if decoupling:\n rows = []\n for d in decoupling:\n refs = d.get('capacitors', [])\n cap_refs = ', '.join(c.get('ref', '') for c in refs) if isinstance(refs, list) else ''\n # IC ref — use ic_ref, fall back to rail name\n ic_ref = d.get('ic_ref') or d.get('ic') or ''\n if not ic_ref or ic_ref == '?':\n ic_ref = d.get('rail', '?') + ' rail'\n total_uf = sum(c.get('farads', 0) for c in refs if isinstance(c, dict)) * 1e6\n total_str = f\"{total_uf:.0f}µF\" if total_uf >= 1 else ''\n rows.append([\n ic_ref,\n d.get('rail', '?'),\n cap_refs,\n total_str,\n ])\n lines.append(\"### Decoupling\")\n lines.append(\"\")\n lines.append(markdown_table(['IC', 'Rail', 'Capacitors', 'Total'], rows))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Signal interfaces\n# ======================================================================\n\ndef section_signal_interfaces(analysis: dict) -> str:\n \"\"\"Generate signal interfaces section.\"\"\"\n lines = [\"## 4. Signal Interfaces\"]\n lines.append(\"\")\n\n bus_analysis = analysis.get('design_analysis', {}).get('bus_analysis', {})\n any_bus = False\n\n for bus_type in ('i2c', 'spi', 'uart', 'can'):\n buses = bus_analysis.get(bus_type, [])\n if not buses:\n continue\n for i, bus in enumerate(buses):\n signals = bus.get('signals', [])\n sig_names = [s.get('name', str(s)) if isinstance(s, dict) else str(s)\n for s in signals]\n # Skip buses with no signal names (empty entries look broken)\n if not sig_names or all(not s for s in sig_names):\n continue\n any_bus = True\n if not any(l.startswith(f\"### {bus_type.upper()}\") for l in lines):\n lines.append(f\"### {bus_type.upper()}\")\n lines.append(\"\")\n bus_id = bus.get('bus_id', f'{bus_type}_{i}')\n lines.append(f\"**{bus_id}**: {', '.join(sig_names[:10])}\")\n lines.append(\"\")\n\n if not any_bus:\n lines.append(\"*No formal buses detected.*\")\n lines.append(\"\")\n\n # Level shifters\n _sa = group_findings(analysis)\n shifters = _sa.get(Det.LEVEL_SHIFTERS, [])\n if shifters:\n lines.append(\"### Level Shifting\")\n lines.append(\"\")\n rows = [[s.get('ref', '?'), s.get('value', ''),\n s.get('low_side_rail', '?'), s.get('high_side_rail', '?')]\n for s in shifters]\n lines.append(_auto(\"level_shifters\",\n markdown_table(['Ref', 'Part', 'Low Side', 'High Side'], rows)))\n lines.append(\"\")\n\n lines.append(_narrative(\"signal_interfaces_notes\",\n \"Describe interface design decisions: \"\n \"pull-up values, termination, protection, signal integrity.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Analog design\n# ======================================================================\n\ndef section_analog_design(analysis: dict, diagrams_dir: str) -> str:\n \"\"\"Generate analog design section.\"\"\"\n lines = [\"## 5. Analog Design\"]\n lines.append(\"\")\n\n sa = group_findings(analysis)\n\n # Voltage dividers\n dividers = sa.get(Det.VOLTAGE_DIVIDERS, [])\n if dividers:\n lines.append(\"### Voltage Dividers\")\n lines.append(\"\")\n rows = []\n for d in dividers:\n mid_net = d.get('mid_net', '?')\n # Replace internal net names with what the divider connects to\n if mid_net.startswith('__unnamed') or mid_net.startswith('Net-'):\n # Check if this is a feedback divider for a regulator\n connections = d.get('mid_point_connections', [])\n if connections:\n # Format as \"U3 FB, C12\" from the connection dicts\n parts = []\n for c in connections[:3]:\n if isinstance(c, dict):\n comp = c.get('component', '')\n pin = c.get('pin_name', '')\n if comp and pin:\n parts.append(f\"{comp} {pin}\")\n elif comp:\n parts.append(comp)\n else:\n parts.append(str(c))\n mid_net = ', '.join(parts) if parts else \"(internal)\"\n else:\n mid_net = \"(internal)\"\n rows.append([\n d.get('r_top', {}).get('ref', '?'),\n d.get('r_bottom', {}).get('ref', '?'),\n f\"{d.get('ratio', 0):.3f}\",\n mid_net,\n ])\n lines.append(markdown_table(['R_top', 'R_bottom', 'Ratio', 'Output Net'], rows))\n lines.append(\"\")\n\n # Filters\n for ftype, label in [(Det.RC_FILTERS, 'RC Filters'), (Det.LC_FILTERS, 'LC Filters')]:\n filters = sa.get(ftype, [])\n if filters:\n lines.append(f\"### {label}\")\n lines.append(\"\")\n rows = []\n for f in filters:\n fc = f.get('cutoff_hz')\n rows.append([\n f.get('type', '?'),\n f.get('resistor', {}).get('ref', '?') if isinstance(f.get('resistor'), dict) else str(f.get('resistor', '?')),\n f.get('capacitor', {}).get('ref', '?') if isinstance(f.get('capacitor'), dict) else str(f.get('capacitor', '?')),\n format_frequency(fc),\n ])\n lines.append(_auto(f\"{ftype}_table\",\n markdown_table(['Type', 'R', 'C', 'Cutoff'], rows)))\n lines.append(\"\")\n\n # Crystal circuits\n crystals = sa.get(Det.CRYSTAL_CIRCUITS, [])\n if crystals:\n lines.append(\"### Crystal / Oscillator\")\n lines.append(\"\")\n for c in crystals:\n freq = c.get('frequency_hz')\n lines.append(_auto(f\"crystal_{c.get('ref', 'X')}\",\n f\"**{c.get('ref', '?')}**: {format_frequency(freq)}\"))\n lines.append(\"\")\n\n # Op-amp circuits\n opamps = sa.get(Det.OPAMP_CIRCUITS, [])\n if opamps:\n lines.append(\"### Op-Amp Circuits\")\n lines.append(\"\")\n rows = []\n for o in opamps:\n rows.append([\n o.get('ref', '?'),\n o.get('value', ''),\n o.get('topology', '?'),\n str(o.get('gain', '—')),\n ])\n lines.append(_auto(\"opamp_table\",\n markdown_table(['Ref', 'Part', 'Topology', 'Gain'], rows)))\n lines.append(\"\")\n\n if not any([dividers, sa.get(Det.RC_FILTERS), sa.get(Det.LC_FILTERS),\n crystals, opamps]):\n lines.append(\"*No analog subcircuits detected.*\")\n lines.append(\"\")\n\n lines.append(_narrative(\"analog_design_notes\",\n \"Describe analog design decisions: component selection rationale, \"\n \"SPICE verification results, tolerance analysis.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Thermal analysis\n# ======================================================================\n\ndef section_thermal(thermal_data: dict | None) -> str:\n \"\"\"Generate thermal analysis section from analyze_thermal output.\"\"\"\n lines = [\"## 6. Thermal Analysis\"]\n lines.append(\"\")\n\n if not thermal_data:\n return None\n\n summary = thermal_data.get('summary', {})\n lines.append(_auto(\"thermal_summary\",\n f\"**Thermal Score:** {summary.get('thermal_score', '—')}/100 | \"\n f\"**Hottest Component:** {summary.get('hottest_component', '—')} | \"\n f\"**Components >85°C:** {summary.get('components_above_85c', 0)}\"))\n lines.append(\"\")\n\n assessments = thermal_data.get('thermal_assessments', [])\n if assessments:\n rows = []\n for a in assessments:\n rows.append([\n a.get('ref', '?'),\n a.get('value', ''),\n a.get('package', ''),\n f\"{a.get('pdiss_w', 0):.2f}W\",\n f\"{a.get('tj_estimated_c', 0):.0f}°C\",\n f\"{a.get('margin_c', 0):.0f}°C\",\n ])\n lines.append(_auto(\"thermal_table\",\n markdown_table(\n ['Ref', 'Part', 'Package', 'Pdiss', 'Tj Est', 'Margin'],\n rows, ['left', 'left', 'left', 'right', 'right', 'right'])))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# EMC considerations\n# ======================================================================\n\ndef section_emc(emc_data: dict | None) -> str:\n \"\"\"Generate EMC section from analyze_emc output.\"\"\"\n lines = [\"## 7. EMC Considerations\"]\n lines.append(\"\")\n\n if not emc_data:\n return None\n\n summary = emc_data.get('summary', {})\n lines.append(_auto(\"emc_summary\",\n f\"**EMC Risk Score:** {summary.get('emc_risk_score', '—')}/100 | \"\n f\"**Critical:** {summary.get('critical', 0)} | \"\n f\"**High:** {summary.get('high', 0)} | \"\n f\"**Medium:** {summary.get('medium', 0)}\"))\n lines.append(\"\")\n\n findings = emc_data.get('findings', [])\n active = [f for f in findings if not f.get('suppressed')]\n if active:\n # Group by category, then show summary + details\n from collections import OrderedDict\n by_category: dict[str, list] = OrderedDict()\n sev_order = {'CRITICAL': 0, 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3, 'INFO': 4}\n for f in sorted(active, key=lambda x: sev_order.get(x.get('severity', 'INFO'), 5)):\n cat = f.get('category', 'other')\n by_category.setdefault(cat, []).append(f)\n\n # Category summary table\n cat_rows = []\n for cat, cat_findings in by_category.items():\n sev_counts = {}\n for f in cat_findings:\n s = f.get('severity', 'INFO')\n sev_counts[s] = sev_counts.get(s, 0) + 1\n sev_str = ', '.join(f\"{c}×{s}\" for s, c in\n sorted(sev_counts.items(),\n key=lambda x: sev_order.get(x[0], 5)))\n cat_rows.append([\n cat.replace('_', ' ').title(),\n str(len(cat_findings)),\n sev_str,\n ])\n lines.append(\"### Findings by Category\")\n lines.append(\"\")\n lines.append(markdown_table(['Category', 'Count', 'Severity Breakdown'], cat_rows))\n lines.append(\"\")\n\n # Top findings detail (limit to most severe)\n top = [f for f in active if f.get('severity') in ('CRITICAL', 'HIGH')][:15]\n if top:\n lines.append(\"### Critical and High Findings\")\n lines.append(\"\")\n detail_rows = []\n for f in top:\n detail_rows.append([\n f.get('severity', '?'),\n f.get('rule_id', '?'),\n f.get('title', ''),\n ])\n lines.append(markdown_table(['Severity', 'Rule', 'Finding'], detail_rows))\n lines.append(\"\")\n lines.append(\"\")\n lines.append(_narrative(\"emc_notes\",\n \"Describe EMC design strategy: shielding, filtering, \"\n \"layout decisions for emissions compliance.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# PCB design\n# ======================================================================\n\ndef section_pcb_design(pcb_data: dict | None) -> str | None:\n \"\"\"Generate PCB design section from analyze_pcb output.\"\"\"\n if not pcb_data:\n return None\n\n lines = [\"## 8. PCB Design Details\"]\n lines.append(\"\")\n\n stats = pcb_data.get('statistics', {})\n if stats:\n rows = [\n ['Copper layers', str(stats.get('copper_layers', '?'))],\n ['Footprints (front/back)', f\"{stats.get('front_footprints', '?')}/{stats.get('back_footprints', '?')}\"],\n ['Track segments', str(stats.get('track_segments', '?'))],\n ['Vias', str(stats.get('via_count', '?'))],\n ['Routing completion', f\"{stats.get('routing_completion', '?')}%\"],\n ]\n lines.append(_auto(\"pcb_stats\",\n markdown_table(['Metric', 'Value'], rows)))\n lines.append(\"\")\n\n # Board outline\n outline = pcb_data.get('board_outline', {})\n if outline:\n w = outline.get('width_mm', '?')\n h = outline.get('height_mm', '?')\n lines.append(_auto(\"board_dimensions\",\n f\"**Board Dimensions:** {w}mm × {h}mm\"))\n lines.append(\"\")\n\n lines.append(_narrative(\"pcb_design_notes\",\n \"Describe PCB layout decisions: stackup, impedance control, \"\n \"routing strategy, DFM considerations.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# BOM summary\n# ======================================================================\n\ndef section_bom_summary(analysis: dict) -> str:\n \"\"\"Generate BOM summary section.\"\"\"\n lines = [\"## 10. BOM Summary\"]\n lines.append(\"\")\n\n bom = analysis.get('bom', [])\n if not bom:\n lines.append(\"*No BOM data available.*\")\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n rows = []\n for item in bom:\n refs = item.get('references', [])\n ref_str = ', '.join(refs[:5])\n if len(refs) > 5:\n ref_str += f\" +{len(refs) - 5}\"\n rows.append([\n ref_str,\n item.get('value', ''),\n item.get('footprint', '').split(':')[-1] if item.get('footprint') else '',\n item.get('mpn', ''),\n str(item.get('quantity', len(refs))),\n ])\n\n lines.append(_auto(\"bom_table\",\n markdown_table(['References', 'Value', 'Footprint', 'MPN', 'Qty'],\n rows[:50],\n ['left', 'left', 'left', 'left', 'right'])))\n if len(rows) > 50:\n lines.append(f\"*... and {len(rows) - 50} more line items.*\")\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Test and debug\n# ======================================================================\n\ndef section_test_debug(analysis: dict) -> str:\n \"\"\"Generate test and debug section.\"\"\"\n lines = [\"## 11. Test and Debug\"]\n lines.append(\"\")\n\n # Debug interfaces\n debug = group_findings(analysis).get(Det.DEBUG_INTERFACES, [])\n if debug:\n rows = [[d.get('ref', '?'), d.get('type', ''), d.get('protocol', '')]\n for d in debug]\n lines.append(_auto(\"debug_interfaces\",\n markdown_table(['Ref', 'Type', 'Protocol'], rows)))\n lines.append(\"\")\n\n lines.append(_narrative(\"test_strategy\",\n \"Describe the testing approach: test points, production test \"\n \"procedures, debug access, programming interface.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Compliance\n# ======================================================================\n\ndef section_compliance(analysis: dict, emc_data: dict | None,\n config: dict) -> str | None:\n \"\"\"Generate compliance and standards section.\"\"\"\n market = config.get('project', {}).get('market', '')\n if not emc_data and not market:\n return None\n\n lines = [\"## 12. Compliance and Standards\"]\n lines.append(\"\")\n if market:\n lines.append(_auto(\"target_market\", f\"**Target Market:** {market.upper()}\"))\n lines.append(\"\")\n\n # EMC test plan\n if emc_data:\n test_plan = emc_data.get('test_plan', {})\n if test_plan:\n lines.append(\"### EMC Test Plan\")\n lines.append(\"\")\n lines.append(_auto(\"emc_test_plan\",\n f\"*See EMC analysis output for detailed test plan.*\"))\n lines.append(\"\")\n\n lines.append(_narrative(\"compliance_notes\",\n \"List applicable standards (FCC, CE, UL), \"\n \"certification strategy, and pre-compliance test results.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Appendices\n# ======================================================================\n\ndef section_appendix_schematics(sch_cache_dir: str,\n analysis: dict,\n sch_cache_abs: str | None = None) -> str:\n \"\"\"Generate appendix with full schematic sheet images.\n\n Args:\n sch_cache_dir: Relative path for markdown image links.\n analysis: Schematic analysis data.\n sch_cache_abs: Absolute path for filesystem checks (falls back to\n sch_cache_dir if not provided).\n \"\"\"\n lines = [\"## Appendix A: Schematic Drawings\"]\n lines.append(\"\")\n\n # Use absolute path for filesystem checks, relative for markdown links\n import os\n check_dir = sch_cache_abs or sch_cache_dir\n if os.path.isdir(check_dir):\n svgs = sorted(f for f in os.listdir(check_dir) if f.endswith('.svg'))\n if svgs:\n for svg_file in svgs:\n name = svg_file.replace('.svg', '').replace('_', ' ')\n lines.append(f\"### {name}\")\n lines.append(\"\")\n lines.append(f\"![{name}]({sch_cache_dir}/{svg_file})\")\n lines.append(\"\")\n else:\n lines.append(\"*No schematic SVGs found. Run kidoc_render.py first.*\")\n else:\n lines.append(f\"*Schematic cache directory not found: {sch_cache_dir}*\")\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# CE Technical File — specialized sections\n# ======================================================================\n\ndef section_ce_product_identification(analysis: dict, config: dict) -> str:\n \"\"\"CE Technical File: product identification.\"\"\"\n lines = [\"## Product Identification\"]\n lines.append(\"\")\n\n project = config.get('project', {})\n rows = [\n ['Product Name', project.get('name', '—')],\n ['Model / Part Number', project.get('number', '—')],\n ['Revision', project.get('revision', '—')],\n ['Manufacturer', project.get('company', '—')],\n ['Intended Use', ''],\n ]\n lines.append(_auto(\"ce_product_id\", markdown_table(['Field', 'Value'], rows)))\n lines.append(\"\")\n lines.append(_narrative(\"ce_intended_use\",\n \"Describe the product's intended use, target environment \"\n \"(indoor/outdoor, industrial/consumer), and user profile.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_ce_essential_requirements(analysis: dict, config: dict) -> str:\n \"\"\"CE Technical File: essential requirements mapping.\"\"\"\n lines = [\"## Essential Requirements\"]\n lines.append(\"\")\n lines.append(\"Mapping of EU directive essential requirements to design evidence.\")\n lines.append(\"\")\n\n # Determine applicable directives based on market\n rows = [\n ['LVD 2014/35/EU', 'Electrical safety', 'EN 62368-1', ''],\n ['EMC 2014/30/EU', 'Electromagnetic compatibility', 'EN 55032, EN 55035', ''],\n ['RoHS 2011/65/EU', 'Hazardous substance restriction', 'EN IEC 63000', ''],\n ]\n\n # Add radio if applicable\n sa = group_findings(analysis)\n if sa.get(Det.RF_CHAINS) or sa.get(Det.RF_MATCHING):\n rows.append(['RED 2014/53/EU', 'Radio equipment', 'EN 300 328, EN 301 489', ''])\n\n lines.append(_auto(\"ce_essential_reqs\",\n markdown_table(\n ['Directive', 'Requirement', 'Harmonized Standard', 'Evidence'],\n rows)))\n lines.append(\"\")\n lines.append(_narrative(\"ce_essential_req_notes\",\n \"For each directive, describe how the design meets the \"\n \"essential requirements. Reference test reports and analysis data.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_ce_harmonized_standards(config: dict) -> str:\n \"\"\"CE Technical File: harmonized standards list.\"\"\"\n lines = [\"## Harmonized Standards Applied\"]\n lines.append(\"\")\n\n # Get standards from config or use defaults for EU market\n reports = config.get('reports', {})\n standards = []\n for doc_def in reports.get('documents', []):\n if doc_def.get('type') == 'ce_technical_file':\n standards = doc_def.get('standards', [])\n break\n\n if not standards:\n standards = ['EN 55032', 'EN 55035', 'EN 62368-1', 'EN IEC 63000']\n\n rows = []\n standard_descriptions = {\n 'EN 55032': ('EMC emissions', 'Limits for electromagnetic disturbances'),\n 'EN 55035': ('EMC immunity', 'Immunity requirements for multimedia equipment'),\n 'EN 62368-1': ('Safety', 'Audio/video, IT and communication technology equipment'),\n 'EN IEC 63000': ('RoHS', 'Technical documentation for hazardous substance assessment'),\n 'EN 300 328': ('Radio', 'Wideband data transmission (2.4 GHz)'),\n 'EN 301 489': ('Radio EMC', 'EMC standard for radio equipment'),\n 'EN 61000-4-2': ('ESD immunity', 'Electrostatic discharge immunity test'),\n 'EN 61000-4-3': ('Radiated immunity', 'Radiated RF electromagnetic field immunity'),\n }\n for std in standards:\n category, desc = standard_descriptions.get(std, ('', std))\n rows.append([std, category, desc])\n\n lines.append(_auto(\"ce_standards\",\n markdown_table(['Standard', 'Category', 'Description'], rows)))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_ce_risk_assessment(analysis: dict, emc_data: dict | None,\n thermal_data: dict | None) -> str:\n \"\"\"CE Technical File: risk assessment.\"\"\"\n lines = [\"## Risk Assessment\"]\n lines.append(\"\")\n\n rows = [\n ['Electrical shock', 'Low voltage (\u003c50V DC)', '', ''],\n ['Overheating / fire', '', '', ''],\n ['EMI emissions', '', '', ''],\n ['ESD susceptibility', '', '', ''],\n ['Mechanical hazard', '', '', ''],\n ]\n\n # Populate from analysis data\n if thermal_data:\n summary = thermal_data.get('summary', {})\n hottest = summary.get('hottest_component', '—')\n above_85 = summary.get('components_above_85c', 0)\n rows[1][1] = f\"Hottest: {hottest}\"\n rows[1][2] = 'HIGH' if above_85 > 0 else 'LOW'\n rows[1][3] = f\"{above_85} components above 85°C\" if above_85 else 'Within limits'\n\n if emc_data:\n summary = emc_data.get('summary', {})\n score = summary.get('emc_risk_score', '—')\n critical = summary.get('critical', 0)\n rows[2][1] = f\"EMC risk score: {score}/100\"\n rows[2][2] = 'HIGH' if critical > 0 else ('MEDIUM' if score and score > 50 else 'LOW')\n rows[2][3] = f\"{critical} critical findings\" if critical else 'Pre-compliance assessment'\n\n # ESD from analysis\n esd_audit = group_findings(analysis).get(Det.ESD_AUDIT, [])\n unprotected = 0\n for e in esd_audit:\n if isinstance(e, dict):\n try:\n cov = float(e.get('coverage', 1.0))\n if cov \u003c 1.0:\n unprotected += 1\n except (TypeError, ValueError):\n pass\n if unprotected:\n rows[3][1] = f\"{unprotected} connectors with gaps\"\n rows[3][2] = 'MEDIUM'\n rows[3][3] = 'Partial ESD protection coverage'\n else:\n rows[3][1] = 'All connectors protected'\n rows[3][2] = 'LOW'\n\n lines.append(_auto(\"ce_risk_assessment\",\n markdown_table(['Hazard', 'Details', 'Risk Level', 'Mitigation'], rows)))\n lines.append(\"\")\n lines.append(_narrative(\"ce_risk_notes\",\n \"Describe risk mitigation measures for each identified hazard. \"\n \"Reference specific design features and test results.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_ce_declaration_of_conformity(config: dict) -> str:\n \"\"\"CE Technical File: Declaration of Conformity template.\"\"\"\n lines = [\"## EU Declaration of Conformity\"]\n lines.append(\"\")\n\n project = config.get('project', {})\n lines.append(_auto(\"ce_doc_template\", \"\\n\".join([\n \"**EU DECLARATION OF CONFORMITY**\",\n \"\",\n f\"**Manufacturer:** {project.get('company', '________________')}\",\n f\"**Product:** {project.get('name', '________________')}\",\n f\"**Model:** {project.get('number', '________________')}\",\n \"\",\n \"We declare under our sole responsibility that the product described above \"\n \"is in conformity with the relevant Union harmonisation legislation:\",\n \"\",\n \"- Directive 2014/35/EU (Low Voltage Directive)\",\n \"- Directive 2014/30/EU (EMC Directive)\",\n \"- Directive 2011/65/EU (RoHS Directive)\",\n \"\",\n \"Harmonized standards applied: *(see Harmonized Standards section)*\",\n \"\",\n \"Signed: ________________ Date: ________________\",\n \"\",\n \"Name: ________________ Position: ________________\",\n ])))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Design Review — specialized sections\n# ======================================================================\n\ndef section_review_summary(analysis: dict, emc_data: dict | None,\n thermal_data: dict | None,\n gate_data: dict | None) -> str:\n \"\"\"Design Review: summary of findings across all analyzers.\"\"\"\n lines = [\"## Review Summary\"]\n lines.append(\"\")\n\n rows = []\n\n # Fab gate\n if gate_data:\n status = gate_data.get('overall_status', '?')\n summary = gate_data.get('summary', {})\n rows.append(['Fab Release Gate', status,\n f\"{summary.get('pass', 0)} pass, {summary.get('fail', 0)} fail\"])\n\n # EMC\n if emc_data:\n summary = emc_data.get('summary', {})\n score = summary.get('emc_risk_score', '—')\n rows.append(['EMC Risk Score', f\"{score}/100\",\n f\"C:{summary.get('critical', 0)} H:{summary.get('high', 0)} \"\n f\"M:{summary.get('medium', 0)}\"])\n\n # Thermal\n if thermal_data:\n summary = thermal_data.get('summary', {})\n score = summary.get('thermal_score', '—')\n rows.append(['Thermal Score', f\"{score}/100\",\n f\"{summary.get('components_above_85c', 0)} above 85°C\"])\n\n # BOM completeness\n stats = analysis.get('statistics', {})\n missing_mpn = stats.get('missing_mpns', 0)\n total = stats.get('total_components', 0)\n if total:\n rows.append(['BOM Completeness',\n f\"{total - missing_mpn}/{total} MPNs\",\n f\"{missing_mpn} missing\" if missing_mpn else 'Complete'])\n\n if rows:\n lines.append(_auto(\"review_summary_table\",\n markdown_table(['Check', 'Status', 'Details'], rows)))\n lines.append(\"\")\n lines.append(_narrative(\"review_overall_assessment\",\n \"Provide an overall assessment of design readiness. \"\n \"Highlight critical risks and recommend go/no-go.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_review_action_items(config: dict) -> str:\n \"\"\"Design Review: action items table.\"\"\"\n lines = [\"## Action Items\"]\n lines.append(\"\")\n lines.append(_auto(\"review_actions\",\n markdown_table(\n ['#', 'Finding', 'Severity', 'Owner', 'Due Date', 'Status'],\n [['1', '', '', '', '', 'OPEN']],\n ['right', 'left', 'left', 'left', 'left', 'left'])))\n lines.append(\"\")\n lines.append(_narrative(\"review_action_notes\",\n \"List action items from the review. Assign owners and due dates. \"\n \"Track resolution status.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# ICD — specialized sections\n# ======================================================================\n\ndef section_icd_interface_list(analysis: dict) -> str:\n \"\"\"ICD: summary table of all external interfaces.\"\"\"\n lines = [\"## Interface List\"]\n lines.append(\"\")\n\n rows = []\n # Connectors from ESD audit (they enumerate all external connectors)\n esd = group_findings(analysis).get(Det.ESD_AUDIT, [])\n for e in esd:\n if isinstance(e, dict):\n rows.append([\n e.get('connector_ref', '?'),\n e.get('connector_value', ''),\n e.get('interface_type', ''),\n str(len(e.get('signal_nets', []))),\n e.get('risk_level', ''),\n ])\n\n if rows:\n lines.append(_auto(\"icd_interface_list\",\n markdown_table(\n ['Connector', 'Type', 'Interface', 'Signals', 'ESD Risk'],\n rows)))\n else:\n lines.append(\"*No external interfaces detected. Add connector analysis data.*\")\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_icd_connector_details(analysis: dict, config: dict) -> str:\n \"\"\"ICD: per-connector pinout and signal details.\"\"\"\n lines = [\"## Connector Details\"]\n lines.append(\"\")\n\n # Get specific connectors from config if specified\n target_connectors = []\n for doc_def in config.get('reports', {}).get('documents', []):\n if doc_def.get('type') == 'icd':\n target_connectors = doc_def.get('connectors', [])\n\n esd = group_findings(analysis).get(Det.ESD_AUDIT, [])\n ic_pins = analysis.get('ic_pin_analysis', {})\n\n for e in esd:\n if not isinstance(e, dict):\n continue\n ref = e.get('connector_ref', '')\n if target_connectors and ref not in target_connectors:\n continue\n\n lines.append(f\"### {ref} — {e.get('connector_value', '')}\")\n lines.append(\"\")\n lines.append(f\"**Interface:** {e.get('interface_type', 'General')}\")\n lines.append(\"\")\n\n # Signal list\n signals = e.get('signal_nets', [])\n protected = set(e.get('protected_nets', []))\n if signals:\n rows = []\n for sig in signals:\n prot = 'Yes' if sig in protected else 'No'\n rows.append([sig, '', '', prot])\n lines.append(_auto(f\"icd_connector_{ref}\",\n markdown_table(\n ['Signal', 'Direction', 'Voltage Level', 'ESD Protected'],\n rows)))\n lines.append(\"\")\n lines.append(_narrative(f\"icd_connector_{ref}_notes\",\n f\"Describe the {ref} interface: protocol, signal levels, \"\n f\"timing requirements, mating connector specification.\"))\n lines.append(\"\")\n\n if not esd:\n lines.append(\"*No connector data available. Run schematic analysis with ESD audit.*\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n\ndef section_icd_electrical_characteristics(analysis: dict) -> str:\n \"\"\"ICD: electrical characteristics summary.\"\"\"\n lines = [\"## Electrical Characteristics\"]\n lines.append(\"\")\n\n # Power domains give voltage levels\n domains = analysis.get('design_analysis', {}).get('power_domains', {})\n domain_groups = domains.get('domain_groups', {})\n\n if domain_groups:\n rows = []\n for domain_name in sorted(domain_groups.keys()) if isinstance(domain_groups, dict) else []:\n rows.append([domain_name, '', '', ''])\n if rows:\n lines.append(_auto(\"icd_voltage_levels\",\n markdown_table(\n ['Voltage Domain', 'Nominal', 'Min', 'Max'],\n rows, ['left', 'right', 'right', 'right'])))\n lines.append(\"\")\n lines.append(_narrative(\"icd_electrical_notes\",\n \"Specify voltage levels, impedance, current limits, \"\n \"and timing requirements for each interface.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Manufacturing — specialized sections\n# ======================================================================\n\ndef section_mfg_assembly_overview(analysis: dict) -> str:\n \"\"\"Manufacturing: assembly overview.\"\"\"\n lines = [\"## Assembly Overview\"]\n lines.append(\"\")\n\n stats = analysis.get('statistics', {})\n rows = [\n ['Total components', str(stats.get('total_components', '?'))],\n ['SMD components', str(stats.get('smd_count', '?'))],\n ['THT components', str(stats.get('tht_count', '?'))],\n ['DNP components', str(stats.get('dnp_count', 0))],\n ['Unique parts', str(stats.get('unique_parts', '?'))],\n ]\n lines.append(_auto(\"mfg_overview\",\n markdown_table(['Metric', 'Count'], rows)))\n lines.append(\"\")\n lines.append(_narrative(\"mfg_overview_notes\",\n \"Describe assembly requirements: lead-free/leaded, \"\n \"reflow profile, hand-solder requirements, special handling.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_mfg_pcb_fab_notes(pcb_data: dict | None) -> str | None:\n \"\"\"Manufacturing: PCB fabrication notes.\"\"\"\n if not pcb_data:\n return None\n\n lines = [\"## PCB Fabrication Notes\"]\n lines.append(\"\")\n\n stats = pcb_data.get('statistics', {})\n outline = pcb_data.get('board_outline', {})\n\n rows = [\n ['Board dimensions', f\"{outline.get('width_mm', '?')}mm × {outline.get('height_mm', '?')}mm\"],\n ['Copper layers', str(stats.get('copper_layers', '?'))],\n ['Board thickness', '1.6mm'],\n ['Copper weight', '1 oz'],\n ['Surface finish', 'HASL / ENIG'],\n ['Solder mask', 'Green'],\n ['Silkscreen', 'White'],\n ['Min trace/space', ''],\n ['Min drill', ''],\n ['IPC class', 'Class 2'],\n ]\n lines.append(_auto(\"mfg_fab_notes\",\n markdown_table(['Parameter', 'Value'], rows)))\n lines.append(\"\")\n lines.append(_narrative(\"mfg_fab_notes_detail\",\n \"Specify impedance control requirements, stackup details, \"\n \"material (FR-4/Rogers), and any special fabrication instructions.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_mfg_assembly_instructions(analysis: dict) -> str:\n \"\"\"Manufacturing: assembly instructions.\"\"\"\n lines = [\"## Assembly Instructions\"]\n lines.append(\"\")\n lines.append(_narrative(\"mfg_assembly_instructions\",\n \"Describe the assembly sequence: paste application, component \"\n \"placement, reflow profile, hand-solder steps, conformal coating, \"\n \"cleaning requirements, and special handling for sensitive components.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\ndef section_mfg_test_procedures(analysis: dict) -> str:\n \"\"\"Manufacturing: production test procedures.\"\"\"\n lines = [\"## Production Test Procedures\"]\n lines.append(\"\")\n\n lines.append(_auto(\"mfg_test_checklist\", \"\\n\".join([\n \"1. Visual inspection (IPC-A-610 Class 2)\",\n \"2. Power-on test: verify all voltage rails\",\n \"3. Functional test: verify communication interfaces\",\n \"4. Programming: flash firmware\",\n \"5. Final inspection and labeling\",\n ])))\n lines.append(\"\")\n lines.append(_narrative(\"mfg_test_details\",\n \"Describe pass/fail criteria for each test step. \"\n \"Include expected voltages, test fixture requirements, \"\n \"and failure modes to watch for.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n\n\n# ======================================================================\n# Mechanical / Environmental\n# ======================================================================\n\ndef section_mechanical_environmental(analysis: dict, pcb_data: dict | None) -> str | None:\n \"\"\"Mechanical and environmental specifications.\"\"\"\n if not pcb_data:\n return None\n\n lines = [\"## 9. Mechanical / Environmental\"]\n lines.append(\"\")\n\n if pcb_data:\n outline = pcb_data.get('board_outline', {})\n if outline:\n lines.append(_auto(\"mech_dimensions\",\n f\"**Board Dimensions:** \"\n f\"{outline.get('width_mm', '?')}mm × \"\n f\"{outline.get('height_mm', '?')}mm\"))\n lines.append(\"\")\n\n lines.append(_narrative(\"mechanical_notes\",\n \"Describe: mounting method, enclosure constraints, \"\n \"connector accessibility, operating temperature range, \"\n \"humidity, vibration requirements.\"))\n lines.append(\"\")\n return \"\\n\".join(lines)\n","content_type":"text/x-python; charset=utf-8","language":"python","size":46997,"content_sha256":"4ac5cdd9a48c69d71967f07db085dc41e6ea0a475234ed69879f8b9b6c7e83a7"},{"filename":"scripts/kidoc_spec.py","content":"#!/usr/bin/env python3\n\"\"\"Document spec parser, validator, and expander for kidoc.\n\nA spec is a JSON dict describing a complete document: its type, title,\naudience, tone, input files, output formats, and an ordered list of\nsection definitions. Specs can be created from built-in templates\n(expanded with defaults) or loaded from user-authored JSON files.\n\nUsage:\n python3 kidoc_spec.py --list # list built-in types\n python3 kidoc_spec.py --expand hdd # expand type to full spec JSON\n python3 kidoc_spec.py --validate spec.json # validate a spec file\n\nZero external dependencies -- Python 3.8+ stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n\nfrom kidoc_templates import DOCUMENT_TEMPLATES, get_document_title\n\n\n# ======================================================================\n# Constants\n# ======================================================================\n\nDEFAULT_SPECS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),\n 'default_specs')\n\nVALID_RENDER_MODES = ('auto', 'kicad-cli', 'crop', 'annotated')\nVALID_NARRATIVE_MODES = ('auto', 'none')\nVALID_TONES = ('technical', 'executive', 'conversational')\n\n# Default section spec values\nSECTION_DEFAULTS = {\n 'render': 'auto',\n 'narrative': 'auto',\n 'focus_refs': [],\n 'highlight_nets': [],\n 'include': [],\n 'questions': [],\n}\n\n\n# ======================================================================\n# Spec loading\n# ======================================================================\n\ndef load_spec(spec_path: str) -> dict:\n \"\"\"Load a JSON spec file, validate, and return the spec dict.\n\n Raises ValueError on invalid JSON or missing required fields.\n \"\"\"\n path = Path(spec_path)\n if not path.exists():\n raise FileNotFoundError(f'Spec file not found: {spec_path}')\n\n with open(path, 'r', encoding='utf-8') as f:\n try:\n spec = json.load(f)\n except json.JSONDecodeError as exc:\n raise ValueError(f'Invalid JSON in {spec_path}: {exc}') from exc\n\n return validate_spec(spec)\n\n\ndef load_builtin_spec(doc_type: str) -> dict:\n \"\"\"Load a built-in spec from default_specs/ or generate from template.\n\n Checks for a pre-generated JSON file first; falls back to\n expand_type_to_spec() if no file exists.\n \"\"\"\n json_path = os.path.join(DEFAULT_SPECS_DIR, f'{doc_type}.json')\n if os.path.isfile(json_path):\n return load_spec(json_path)\n return expand_type_to_spec(doc_type)\n\n\ndef expand_type_to_spec(doc_type: str) -> dict:\n \"\"\"Expand a type name into a full spec dict with sections.\n\n Uses DOCUMENT_TEMPLATES to populate the section list with defaults.\n Raises ValueError if the type is unknown.\n \"\"\"\n if doc_type not in DOCUMENT_TEMPLATES:\n available = ', '.join(sorted(DOCUMENT_TEMPLATES))\n raise ValueError(\n f'Unknown document type: {doc_type!r}. '\n f'Available types: {available}'\n )\n\n template = DOCUMENT_TEMPLATES[doc_type]\n\n sections = []\n for section_name in template['sections']:\n section = {\n 'id': section_name,\n 'type': section_name,\n }\n section.update({k: _deep_copy_default(v)\n for k, v in SECTION_DEFAULTS.items()})\n sections.append(section)\n\n spec = {\n 'type': doc_type,\n 'title': template['name'],\n 'audience': '',\n 'tone': 'technical',\n 'schematic': '',\n 'pcb': '',\n 'default_formats': list(template.get('default_formats', ['pdf'])),\n 'sections': sections,\n }\n\n return spec\n\n\n# ======================================================================\n# Validation\n# ======================================================================\n\ndef validate_spec(spec: dict) -> dict:\n \"\"\"Fill defaults for missing fields and validate required fields.\n\n Returns the (possibly modified) spec dict.\n Raises ValueError on invalid data.\n \"\"\"\n if not isinstance(spec, dict):\n raise ValueError('Spec must be a JSON object (dict)')\n\n # Top-level defaults\n spec.setdefault('type', 'custom')\n spec.setdefault('title', get_document_title(spec['type'])\n if spec['type'] in DOCUMENT_TEMPLATES\n else 'Custom Document')\n spec.setdefault('audience', '')\n spec.setdefault('tone', 'technical')\n spec.setdefault('schematic', '')\n spec.setdefault('pcb', '')\n spec.setdefault('default_formats', ['pdf'])\n\n # Validate tone\n if spec['tone'] not in VALID_TONES:\n raise ValueError(\n f'Invalid tone: {spec[\"tone\"]!r}. '\n f'Must be one of: {\", \".join(VALID_TONES)}'\n )\n\n # Validate default_formats\n if not isinstance(spec['default_formats'], list):\n raise ValueError('default_formats must be a list')\n\n # Sections\n if 'sections' not in spec:\n raise ValueError('Spec must contain a \"sections\" list')\n\n if not isinstance(spec['sections'], list):\n raise ValueError('\"sections\" must be a list')\n\n seen_ids = set()\n for i, section in enumerate(spec['sections']):\n if not isinstance(section, dict):\n raise ValueError(f'Section {i} must be a dict')\n\n # Required: id and type\n if 'id' not in section:\n raise ValueError(f'Section {i} missing required field \"id\"')\n if 'type' not in section:\n section['type'] = section['id']\n\n sid = section['id']\n if sid in seen_ids:\n raise ValueError(f'Duplicate section id: {sid!r}')\n seen_ids.add(sid)\n\n # Fill section defaults\n for key, default_val in SECTION_DEFAULTS.items():\n section.setdefault(key, _deep_copy_default(default_val))\n\n # Validate render mode\n if section['render'] not in VALID_RENDER_MODES:\n raise ValueError(\n f'Section {sid!r}: invalid render mode {section[\"render\"]!r}. '\n f'Must be one of: {\", \".join(VALID_RENDER_MODES)}'\n )\n\n # Validate narrative mode\n if section['narrative'] not in VALID_NARRATIVE_MODES:\n raise ValueError(\n f'Section {sid!r}: invalid narrative mode '\n f'{section[\"narrative\"]!r}. '\n f'Must be one of: {\", \".join(VALID_NARRATIVE_MODES)}'\n )\n\n # Validate list fields\n for list_field in ('focus_refs', 'highlight_nets',\n 'include', 'questions'):\n if not isinstance(section.get(list_field, []), list):\n raise ValueError(\n f'Section {sid!r}: {list_field!r} must be a list'\n )\n\n return spec\n\n\n# ======================================================================\n# Accessors\n# ======================================================================\n\ndef get_section_types(spec: dict) -> list[str]:\n \"\"\"Extract the ordered list of section type names from a spec.\"\"\"\n return [s['type'] for s in spec.get('sections', [])]\n\n\ndef get_section_spec(spec: dict, section_id: str) -> dict | None:\n \"\"\"Get the spec dict for a single section by id.\n\n Returns None if no section with the given id exists.\n \"\"\"\n for section in spec.get('sections', []):\n if section['id'] == section_id:\n return section\n return None\n\n\ndef list_builtin_types() -> list[str]:\n \"\"\"List available built-in document types (sorted).\"\"\"\n return sorted(DOCUMENT_TEMPLATES.keys())\n\n\n# ======================================================================\n# Output\n# ======================================================================\n\ndef save_spec(spec: dict, output_path: str) -> None:\n \"\"\"Write a spec dict to a JSON file.\"\"\"\n path = Path(output_path)\n path.parent.mkdir(parents=True, exist_ok=True)\n with open(path, 'w', encoding='utf-8') as f:\n json.dump(spec, f, indent=2)\n f.write('\\n')\n\n\n# ======================================================================\n# Helpers\n# ======================================================================\n\ndef _deep_copy_default(val):\n \"\"\"Return a copy of a default value (handles lists, dicts, scalars).\"\"\"\n if isinstance(val, list):\n return list(val)\n if isinstance(val, dict):\n return dict(val)\n return val\n\n\n# ======================================================================\n# CLI\n# ======================================================================\n\ndef main():\n parser = argparse.ArgumentParser(\n description='kidoc spec tool -- parse, validate, and expand '\n 'document specs'\n )\n group = parser.add_mutually_exclusive_group(required=True)\n group.add_argument('--list', action='store_true',\n help='List available built-in document types')\n group.add_argument('--expand', metavar='TYPE',\n help='Expand a type name to a full spec JSON')\n group.add_argument('--validate', metavar='FILE',\n help='Validate a spec JSON file')\n\n args = parser.parse_args()\n\n if args.list:\n types = list_builtin_types()\n for t in types:\n title = get_document_title(t)\n print(f' {t:24s} {title}')\n print(f'\\n{len(types)} types available.')\n return\n\n if args.expand:\n try:\n spec = expand_type_to_spec(args.expand)\n except ValueError as exc:\n print(f'Error: {exc}', file=sys.stderr)\n sys.exit(1)\n json.dump(spec, sys.stdout, indent=2)\n sys.stdout.write('\\n')\n return\n\n if args.validate:\n try:\n spec = load_spec(args.validate)\n n_sections = len(spec.get('sections', []))\n print(f'OK: {args.validate} -- type={spec[\"type\"]!r}, '\n f'{n_sections} sections')\n except (FileNotFoundError, ValueError) as exc:\n print(f'FAIL: {args.validate} -- {exc}', file=sys.stderr)\n sys.exit(1)\n return\n\n\nif __name__ == '__main__':\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10242,"content_sha256":"55115fa369f5b4a8fe31301efe2e3a961e229ac3f03a0d34875edd697043537c"},{"filename":"scripts/kidoc_tables.py","content":"\"\"\"Markdown table formatting and unit formatting utilities.\n\nZero external dependencies — Python stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\n\ndef markdown_table(headers: list[str], rows: list[list[str]],\n alignments: list[str] | None = None) -> str:\n \"\"\"Generate a markdown table with proper column padding.\n\n *alignments*: list of ``'left'``, ``'center'``, or ``'right'`` per column.\n Defaults to left-aligned.\n \"\"\"\n if not headers:\n return ''\n\n n_cols = len(headers)\n if alignments is None:\n alignments = ['left'] * n_cols\n\n # Compute column widths\n widths = [len(h) for h in headers]\n for row in rows:\n for i, cell in enumerate(row[:n_cols]):\n widths[i] = max(widths[i], len(str(cell)))\n\n def _pad(text: str, width: int, align: str) -> str:\n text = str(text)\n if align == 'right':\n return text.rjust(width)\n elif align == 'center':\n return text.center(width)\n return text.ljust(width)\n\n # Header row\n header_line = '| ' + ' | '.join(\n _pad(h, widths[i], alignments[i]) for i, h in enumerate(headers)\n ) + ' |'\n\n # Separator row\n sep_parts = []\n for i, a in enumerate(alignments):\n w = widths[i]\n if a == 'center':\n sep_parts.append(':' + '-' * max(w - 2, 1) + ':')\n elif a == 'right':\n sep_parts.append('-' * max(w - 1, 1) + ':')\n else:\n sep_parts.append('-' * w)\n sep_line = '| ' + ' | '.join(sep_parts) + ' |'\n\n # Data rows\n data_lines = []\n for row in rows:\n padded = []\n for i in range(n_cols):\n cell = str(row[i]) if i \u003c len(row) else ''\n padded.append(_pad(cell, widths[i], alignments[i]))\n data_lines.append('| ' + ' | '.join(padded) + ' |')\n\n return '\\n'.join([header_line, sep_line] + data_lines)\n\n\n# ---------------------------------------------------------------------------\n# Unit formatters\n# ---------------------------------------------------------------------------\n\ndef format_voltage(v: float | None) -> str:\n \"\"\"Format a voltage value: ``3.3V``, ``1.8V``, ``—`` for None.\"\"\"\n if v is None:\n return '—'\n if abs(v) >= 1:\n return f\"{v:.1f}V\" if v != int(v) else f\"{int(v)}V\"\n return f\"{v * 1000:.0f}mV\"\n\n\ndef format_frequency(hz: float | None) -> str:\n \"\"\"Format frequency: ``1.23kHz``, ``48.0MHz``, ``—`` for None.\"\"\"\n if hz is None:\n return '—'\n if hz >= 1e9:\n return f\"{hz / 1e9:.1f}GHz\"\n if hz >= 1e6:\n return f\"{hz / 1e6:.1f}MHz\"\n if hz >= 1e3:\n return f\"{hz / 1e3:.1f}kHz\"\n return f\"{hz:.0f}Hz\"\n\n\ndef format_current(a: float | None) -> str:\n \"\"\"Format current: ``500mA``, ``2.5A``, ``—`` for None.\"\"\"\n if a is None:\n return '—'\n if abs(a) >= 1:\n return f\"{a:.1f}A\" if a != int(a) else f\"{int(a)}A\"\n if abs(a) >= 0.001:\n return f\"{a * 1000:.0f}mA\"\n return f\"{a * 1e6:.0f}µA\"\n\n\ndef format_capacitance(f: float | None) -> str:\n \"\"\"Format capacitance: ``100nF``, ``10µF``, ``—`` for None.\"\"\"\n if f is None:\n return '—'\n if f >= 1e-3:\n return f\"{f * 1e3:.0f}mF\"\n if f >= 1e-6:\n return f\"{f * 1e6:.0f}µF\"\n if f >= 1e-9:\n return f\"{f * 1e9:.0f}nF\"\n return f\"{f * 1e12:.0f}pF\"\n\n\ndef format_resistance(ohms: float | None) -> str:\n \"\"\"Format resistance: ``10kΩ``, ``4.7Ω``, ``—`` for None.\"\"\"\n if ohms is None:\n return '—'\n if ohms >= 1e6:\n return f\"{ohms / 1e6:.1f}MΩ\"\n if ohms >= 1e3:\n return f\"{ohms / 1e3:.1f}kΩ\" if ohms != int(ohms / 1e3) * 1e3 else f\"{int(ohms / 1e3)}kΩ\"\n return f\"{ohms:.1f}Ω\" if ohms != int(ohms) else f\"{int(ohms)}Ω\"\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3767,"content_sha256":"ef667b72f174c3206558a8107a136a4a006a853625f71588e4d965370c564efe"},{"filename":"scripts/kidoc_templates.py","content":"\"\"\"Document type template definitions for kidoc.\n\nEach document type defines its section list, default output formats,\nand type-specific options. Used by kidoc_scaffold.py to determine\nwhich sections to generate.\n\nZero external dependencies — constants only.\n\"\"\"\n\nfrom __future__ import annotations\n\n\nDOCUMENT_TEMPLATES = {\n \"hdd\": {\n \"name\": \"Hardware Design Description\",\n \"sections\": [\n \"front_matter\", \"executive_summary\", \"system_overview\", \"power_design\",\n \"signal_interfaces\", \"analog_design\", \"thermal_analysis\",\n \"emc_analysis\", \"pcb_design\", \"mechanical_environmental\",\n \"bom_summary\", \"test_debug\", \"compliance\",\n \"appendix_schematics\",\n ],\n \"default_formats\": [\"pdf\", \"docx\"],\n },\n \"ce_technical_file\": {\n \"name\": \"CE Technical File\",\n \"sections\": [\n \"front_matter\", \"ce_product_identification\",\n \"ce_essential_requirements\", \"ce_harmonized_standards\",\n \"ce_risk_assessment\", \"emc_analysis\", \"thermal_analysis\",\n \"ce_declaration_of_conformity\",\n \"bom_summary\", \"appendix_schematics\",\n ],\n \"default_formats\": [\"pdf\"],\n \"pdfa\": True,\n },\n \"design_review\": {\n \"name\": \"Design Review Package\",\n \"sections\": [\n \"front_matter\", \"executive_summary\", \"review_summary\",\n \"system_overview\", \"power_design\",\n \"emc_analysis\", \"thermal_analysis\",\n \"bom_summary\", \"review_action_items\",\n ],\n \"default_formats\": [\"pdf\"],\n },\n \"icd\": {\n \"name\": \"Interface Control Document\",\n \"sections\": [\n \"front_matter\", \"system_overview\",\n \"icd_interface_list\", \"icd_connector_details\",\n \"signal_interfaces\", \"icd_electrical_characteristics\",\n ],\n \"default_formats\": [\"pdf\", \"docx\"],\n },\n \"manufacturing\": {\n \"name\": \"Manufacturing Transfer Package\",\n \"sections\": [\n \"front_matter\", \"mfg_assembly_overview\",\n \"bom_summary\", \"mfg_pcb_fab_notes\",\n \"mfg_assembly_instructions\", \"mfg_test_procedures\",\n \"appendix_schematics\",\n ],\n \"default_formats\": [\"pdf\"],\n },\n \"schematic_review\": {\n \"name\": \"Schematic Review Report\",\n \"sections\": [\n \"front_matter\", \"executive_summary\", \"system_overview\",\n \"power_design\", \"signal_interfaces\", \"analog_design\",\n \"bom_summary\", \"appendix_schematics\",\n ],\n \"default_formats\": [\"pdf\"],\n },\n \"power_analysis\": {\n \"name\": \"Power Analysis Report\",\n \"sections\": [\n \"front_matter\", \"executive_summary\", \"power_design\",\n \"thermal_analysis\", \"emc_analysis\", \"bom_summary\",\n \"appendix_schematics\",\n ],\n \"default_formats\": [\"pdf\"],\n },\n \"emc_report\": {\n \"name\": \"EMC Pre-Compliance Report\",\n \"sections\": [\n \"front_matter\", \"executive_summary\",\n \"emc_analysis\", \"compliance\",\n \"appendix_schematics\",\n ],\n \"default_formats\": [\"pdf\"],\n },\n}\n\n\ndef get_template(doc_type: str) -> dict:\n \"\"\"Get the template for a document type, with defaults fallback.\"\"\"\n return DOCUMENT_TEMPLATES.get(doc_type, DOCUMENT_TEMPLATES['hdd'])\n\n\ndef get_section_list(doc_type: str, config: dict | None = None) -> list[str]:\n \"\"\"Get the section list for a document type, allowing config overrides.\"\"\"\n template = get_template(doc_type)\n sections = list(template['sections'])\n\n # Allow config to override sections\n if config:\n reports = config.get('reports', {})\n for doc_def in reports.get('documents', []):\n if doc_def.get('type') == doc_type and 'sections' in doc_def:\n sections = doc_def['sections']\n break\n\n return sections\n\n\ndef get_document_title(doc_type: str) -> str:\n \"\"\"Get the display name for a document type.\"\"\"\n template = get_template(doc_type)\n return template['name']\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4063,"content_sha256":"6bd2d25813361379a5dcb94618473f69fd127ebaa6ae6e9571914f3c6b08f1d8"},{"filename":"scripts/kidoc_venv.py","content":"\"\"\"Virtual environment manager for kidoc report generation.\n\nCreates and manages a project-local venv at ``reports/.venv/`` with the\ndependencies needed for PDF/DOCX generation. Never touches the user's\nglobal or user Python environment.\n\nZero external dependencies — Python stdlib only.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport platform\nimport subprocess\nimport sys\n\n\nREQUIREMENTS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),\n 'requirements.txt')\n\n\ndef venv_dir(project_dir: str) -> str:\n \"\"\"Return the path to the venv directory.\"\"\"\n return os.path.join(project_dir, 'reports', '.venv')\n\n\ndef venv_python(project_dir: str) -> str:\n \"\"\"Return the path to the venv Python executable.\"\"\"\n vd = venv_dir(project_dir)\n if platform.system() == 'Windows':\n return os.path.join(vd, 'Scripts', 'python.exe')\n return os.path.join(vd, 'bin', 'python')\n\n\ndef venv_pip(project_dir: str) -> str:\n \"\"\"Return the path to the venv pip executable.\"\"\"\n vd = venv_dir(project_dir)\n if platform.system() == 'Windows':\n return os.path.join(vd, 'Scripts', 'pip.exe')\n return os.path.join(vd, 'bin', 'pip')\n\n\ndef is_venv_ready(project_dir: str) -> bool:\n \"\"\"Check if the venv exists and has required packages.\"\"\"\n py = venv_python(project_dir)\n if not os.path.isfile(py):\n return False\n # Quick check: try importing the key packages\n try:\n result = subprocess.run(\n [py, '-c', 'import reportlab; import docx; import odf; import PIL'],\n capture_output=True, timeout=10)\n return result.returncode == 0\n except (subprocess.TimeoutExpired, OSError):\n return False\n\n\ndef ensure_venv(project_dir: str, quiet: bool = False) -> str:\n \"\"\"Ensure the venv exists and has required packages.\n\n Creates the venv and installs dependencies if needed.\n Returns the path to the venv Python executable.\n \"\"\"\n py = venv_python(project_dir)\n\n if is_venv_ready(project_dir):\n return py\n\n vd = venv_dir(project_dir)\n _log = (lambda msg: None) if quiet else (lambda msg: print(msg, file=sys.stderr))\n\n # Create venv if it doesn't exist\n if not os.path.isfile(py):\n _log(f\"Creating venv at {vd}\")\n os.makedirs(os.path.dirname(vd), exist_ok=True)\n subprocess.run([sys.executable, '-m', 'venv', vd], check=True)\n\n # Install/upgrade dependencies\n pip = venv_pip(project_dir)\n if not os.path.isfile(pip):\n # Fallback: use venv python -m pip\n pip_cmd = [py, '-m', 'pip']\n else:\n pip_cmd = [pip]\n\n _log(\"Installing report generation dependencies...\")\n cmd = pip_cmd + ['install', '-q', '-r', REQUIREMENTS_FILE]\n result = subprocess.run(cmd, capture_output=True, text=True)\n if result.returncode != 0:\n _log(f\"pip install failed: {result.stderr}\")\n raise RuntimeError(f\"Failed to install dependencies: {result.stderr}\")\n\n _log(\"Venv ready.\")\n return py\n","content_type":"text/x-python; charset=utf-8","language":"python","size":3005,"content_sha256":"8acf27be31334ebedaa7682ed9505bbfd7952432bdb8b0148ac5490c839ad0f8"},{"filename":"scripts/README.md","content":"# kidoc Scripts — Developer Reference\n\nEngineering documentation generation scripts.\n\n## Top-level scripts\n\n| Script | Input | Purpose | Deps |\n|--------|-------|---------|------|\n| `kidoc_scaffold.py` | project dir + config | Markdown scaffold with GENERATED markers and NARRATIVE placeholders | zero-dep |\n| `kidoc_generate.py` | markdown | Orchestrator — dispatches to venv for PDF/DOCX/ODT | zero-dep |\n| `kidoc_orchestrator.py` | analysis JSON + project | Figure generation coordinator — augments analysis, calls run_all() | zero-dep |\n| `kidoc_diagrams.py` | analysis JSON | Figure generation CLI — runs all registered generators | venv (matplotlib) |\n| `kidoc_narrative.py` | analysis JSON | LLM narrative context builder | zero-dep |\n| `kidoc_narrative_extractors.py` | — | 12 section data extractors + registry | zero-dep |\n| `kidoc_narrative_augment.py` | — | Datasheet, SPICE, cross-reference augmentation | zero-dep |\n| `kidoc_narrative_config.py` | — | Section titles, writing guidance, questions (pure data) | zero-dep |\n| `kidoc_pdf.py` | markdown | PDF via ReportLab + svglib (vector SVG embedding) | venv |\n| `kidoc_html.py` | markdown | Self-contained HTML with inline SVGs | zero-dep |\n| `kidoc_docx.py` | markdown | DOCX via python-docx + Pillow | venv |\n| `kidoc_odt.py` | markdown | ODT via odfpy + Pillow | venv |\n| `kidoc_raster.py` | — | Shared SVG→PNG rasterization utility | Pillow |\n| `kidoc_md_parser.py` | — | Shared markdown to element list parser | zero-dep |\n| `kidoc_spec.py` | — | Document spec loading, validation, expansion (8 built-in types) | zero-dep |\n| `kidoc_sections.py` | — | Section content generators for scaffold | zero-dep |\n| `kidoc_tables.py` | — | Markdown table formatting + unit formatters | zero-dep |\n| `kidoc_templates.py` | — | Document type section list definitions | zero-dep |\n| `kidoc_venv.py` | — | Project-local venv bootstrap | zero-dep |\n| `kidoc_datasheet.py` | — | Datasheet extraction + comparison tables | zero-dep |\n| `kicad_cli.py` | — | kicad-cli auto-detection (PATH, flatpak, macOS, Windows) | zero-dep |\n| `requirements.txt` | — | Pinned deps for reports/.venv/ | — |\n\n## Figure engine (`figures/`)\n\n```\nfigures/\n├── __init__.py # Exports run_all(), FigureTheme\n├── registry.py # @register decorator + GeneratorEntry dataclass\n├── runner.py # prepare → hash-check → render pipeline\n│\n├── lib/ # Shared rendering library\n│ ├── svg_builder.py # SVG element builder with gradient support\n│ ├── styles.py # Drawing helpers (gradient_box, shadow_box, etc.)\n│ ├── theme.py # FigureTheme dataclass + color math\n│ ├── color_theme.py # KiCad schematic/PCB color palettes\n│ ├── layer_presets.py # PCB layer visibility presets\n│ ├── schematic_constants.py # Named KiCad rendering constants\n│ ├── analysis_helpers.py # Shared data extraction (build_pin_nets)\n│ ├── svg_embed.py # SVG→ReportLab converter (3-tier fallback)\n│ └── svg_to_png.py # SVG→PNG rasterizer (Pillow-based)\n│\n├── renderers/ # Schematic + PCB SVG renderers\n│ ├── _path_setup.py # Cross-skill sys.path setup for sexp_parser\n│ ├── schematic.py # render_schematic() — full sheets, crops, overlays\n│ ├── pcb.py # render_pcb() — layer presets, net highlighting\n│ ├── sch_graphics.py # Symbol body graphics extraction\n│ └── pcb_graphics.py # Footprint/track/via extraction\n│\n└── generators/ # One folder per figure type (auto-discovered)\n ├── _mpl_common.py # Shared matplotlib theme bridge\n ├── power_tree/ # Power distribution tree diagram\n ├── architecture/ # System block diagram\n ├── bus_topology/ # I2C/SPI/UART/CAN bus diagram\n ├── pinout/ # Connector pinout diagrams (multi-output)\n ├── schematic_overview/ # Full-sheet schematic SVGs\n ├── schematic_crop/ # Subsystem crop SVGs (multi-output)\n ├── pcb_views/ # PCB layer preset renders (multi-output)\n ├── thermal_margin/ # Thermal margin bar chart (matplotlib)\n ├── emc_severity/ # EMC findings stacked bars (matplotlib)\n ├── spice_validation/ # SPICE scatter plot (matplotlib)\n └── monte_carlo/ # Monte Carlo histogram (matplotlib)\n```\n\nEach generator provides `prepare(analysis, config) → dict | None` and `render(data, output_path, theme) → str | None`. The runner writes each prepared dict to a `.json` file (cache key) and skips rendering if the JSON hasn't changed.\n\n## Rendering pipeline\n\n1. Parse `.kicad_sch` via `sexp_parser.parse_file()` (from kicad skill)\n2. Extract symbol graphics (body shapes, pins) via `sch_graphics.extract_symbol_graphics()`\n3. Extract connectivity (wires, labels, junctions) from the schematic\n4. Render all elements to SVG in back-to-front order\n5. Optionally crop to bounding box or add analysis overlays\n\n### Coordinate system\n\n- KiCad schematic placement: millimetres, Y-down\n- Symbol library internals: millimetres, Y-up (math convention)\n- SVG output: millimetres, Y-down\n- Transform: mirror → rotate → translate with Y-axis flip (`abs_y = cy - rpy`)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5680,"content_sha256":"1fb6aeff39fff6a1480aea4a41bad651faeb3c38926579a865b8aae4ee37914a"},{"filename":"scripts/requirements.txt","content":"reportlab>=4.0,\u003c5\npython-docx>=1.0,\u003c2\nodfpy>=1.4,\u003c2\nPillow>=10.0,\u003c14\nsvglib>=1.5,\u003c2\nmatplotlib>=3.5,\u003c4\n","content_type":"text/plain; charset=utf-8","language":null,"size":103,"content_sha256":"d1c11e791dd7880cf8b92f3923659111ed587d52258bb2f6e0cf251823fffade"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"kidoc — Engineering Documentation Skill","type":"text"}]},{"type":"paragraph","content":[{"text":"Generate professional engineering documentation from KiCad project files.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Start","type":"text"}]},{"type":"paragraph","content":[{"text":"One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 skills/kidoc/scripts/kidoc_scaffold.py \\\n --project-dir /path/to/kicad/project \\\n --type hdd \\\n --output reports/HDD.md","type":"text"}]},{"type":"paragraph","content":[{"text":"This auto-detects ","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":" files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders.","type":"text"}]},{"type":"paragraph","content":[{"text":"To produce a PDF:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 skills/kidoc/scripts/kidoc_generate.py \\\n --project-dir /path/to/kicad/project \\\n --doc reports/HDD.md \\\n --format pdf","type":"text"}]},{"type":"paragraph","content":[{"text":"Creates ","type":"text"},{"text":"reports/.venv/","type":"text","marks":[{"type":"code_inline"}]},{"text":" automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workflow","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Generate scaffold","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"kidoc_scaffold.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" auto-runs all available analyses, renders schematics, generates diagrams, and writes the markdown scaffold.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fill narratives","type":"text","marks":[{"type":"strong"}]},{"text":" — The agent reads the scaffold and writes engineering prose for each ","type":"text"},{"text":"\u003c!-- NARRATIVE: section_name -->","type":"text","marks":[{"type":"code_inline"}]},{"text":" placeholder. The engineer reviews and edits.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Regenerate","type":"text","marks":[{"type":"strong"}]},{"text":" — On re-run, data sections between ","type":"text"},{"text":"\u003c!-- GENERATED: section_id -->","type":"text","marks":[{"type":"code_inline"}]},{"text":" markers update from fresh analysis; user-written narrative content is preserved.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render output","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"kidoc_generate.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" produces PDF, HTML, DOCX, or ODT.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Document Types","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Name","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Key Sections","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"hdd","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Hardware Design Description","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"System overview, power, signals, analog, thermal, EMC, PCB, mechanical, BOM, test, compliance","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ce_technical_file","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CE Technical File","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Product ID, essential requirements, harmonized standards, risk assessment, Declaration of Conformity","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"design_review","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Design Review Package","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Review summary (cross-analyzer scores), findings, action items","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"icd","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Interface Control Document","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Interface list, per-connector pinout details, electrical characteristics","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"manufacturing","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manufacturing Transfer Package","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Assembly overview, PCB fab notes, assembly instructions, test procedures","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"schematic_review","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Schematic Review Report","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"System overview, power, signals, analog, BOM, schematic appendix","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"power_analysis","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Power Analysis Report","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Power design, thermal, EMC, BOM","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"emc_report","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EMC Pre-Compliance Report","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"EMC analysis, compliance, schematic appendix","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Custom Reports","type":"text"}]},{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"--spec","type":"text","marks":[{"type":"code_inline"}]},{"text":" to generate reports with arbitrary section ordering:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 skills/kidoc/scripts/kidoc_scaffold.py \\\n --project-dir . --spec my-report.json --output reports/custom.md","type":"text"}]},{"type":"paragraph","content":[{"text":"Spec format (JSON):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"type\": \"custom\",\n \"title\": \"USB Interface Analysis\",\n \"sections\": [\n {\"id\": \"front_matter\", \"type\": \"front_matter\"},\n {\"id\": \"signal_interfaces\", \"type\": \"signal_interfaces\"},\n {\"id\": \"bom\", \"type\": \"bom_summary\"}\n ]\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Each section's ","type":"text"},{"text":"type","type":"text","marks":[{"type":"code_inline"}]},{"text":" must match a known section type (same names used in the document types table above). The ","type":"text"},{"text":"id","type":"text","marks":[{"type":"code_inline"}]},{"text":" field is a unique key for that section instance.","type":"text"}]},{"type":"paragraph","content":[{"text":"To see the full default spec for any built-in type:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 skills/kidoc/scripts/kidoc_spec.py --expand hdd\npython3 skills/kidoc/scripts/kidoc_spec.py --list","type":"text"}]},{"type":"paragraph","content":[{"text":"The ","type":"text"},{"text":"--spec","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag also works with ","type":"text"},{"text":"kidoc_generate.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" (uses the spec title as fallback project name).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Schematic and PCB Rendering","type":"text"}]},{"type":"paragraph","content":[{"text":"Rendering is integrated into the figure generation engine. The orchestrator and scaffold automatically render schematics and PCB views as part of document generation:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Generate all figures (diagrams + schematic/PCB renders) from analysis JSON\npython3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --output reports/figures/\n\n# Full orchestration with spec, analysis, and project files\npython3 skills/kidoc/scripts/kidoc_orchestrator.py --analysis schematic.json \\\n --project-dir . --output reports/figures/","type":"text"}]},{"type":"paragraph","content":[{"text":"The figure generators support: full-sheet rendering (root + all sub-sheets), subsystem cropping (","type":"text"},{"text":"focus_refs","type":"text","marks":[{"type":"code_inline"}]},{"text":" in spec sections), net highlighting, pin-level net annotation, and all PCB layer presets. These options are configured in the document spec or passed through the analysis dict.","type":"text"}]},{"type":"paragraph","content":[{"text":"Rendering features available through the generator framework:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Crop","type":"text","marks":[{"type":"strong"}]},{"text":": Focus on a subsystem bounding box around specific component refs","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Focus/dim","type":"text","marks":[{"type":"strong"}]},{"text":": Show focused components at full opacity, dim the rest to 15%","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Highlight nets","type":"text","marks":[{"type":"strong"}]},{"text":": Color-trace specific nets via BFS","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pin nets","type":"text","marks":[{"type":"strong"}]},{"text":": Annotate pin-level net names at pin tips","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For direct programmatic access, use ","type":"text"},{"text":"figures.renderers","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"python"},"content":[{"text":"from figures.renderers import render_schematic, render_pcb\nrender_schematic('design.kicad_sch', 'output/', crop_refs=['R1', 'R2'], highlight_nets=['VCC'])\nrender_pcb('board.kicad_pcb', 'output/', preset_name='assembly-front')","type":"text"}]},{"type":"paragraph","content":[{"text":"Layer presets:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Preset","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Shows","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"assembly-front","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Front silk, fab, pads, outline","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"assembly-back","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Back silk, fab, pads, outline (mirrored)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"routing-front","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Front copper, pads, vias, outline","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"routing-back","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Back copper, pads, vias, outline","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"routing-all","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"All copper layers, pads, vias, zones","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"power","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Power planes, vias, zone outlines","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Additional options: ","type":"text"},{"text":"--highlight-nets","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--crop-refs","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--crop x,y,w,h","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--mirror","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--overlay annotations.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (callout boxes with leader lines).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Block Diagrams","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --all --output diagrams/\npython3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --power-tree --output diagrams/\npython3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --bus-topology --output diagrams/\npython3 skills/kidoc/scripts/kidoc_diagrams.py --analysis schematic.json --architecture --output diagrams/","type":"text"}]},{"type":"paragraph","content":[{"text":"Generated from schematic analysis JSON. Power trees show regulator topology with inductor values, capacitor summaries, and output voltages.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Output Formats","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Format","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SVG Handling","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dependencies","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Markdown","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Image references","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Zero-dep","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"HTML","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Inlined as vector","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Zero-dep","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PDF","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Vector via svglib, custom converter fallback, raster fallback","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Venv (","type":"text"},{"text":"reports/.venv/","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"DOCX","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rasterized to 300 DPI PNG","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Venv","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ODT","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rasterized to 300 DPI PNG","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Venv","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"PDF output includes a styled cover page, table of contents, formatted tables with alternating rows, and vector SVG diagrams.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Configuration","type":"text"}]},{"type":"paragraph","content":[{"text":"Report settings live in ","type":"text"},{"text":".kicad-happy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" under the ","type":"text"},{"text":"\"reports\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" key. Config files cascade: ","type":"text"},{"text":"~/.kicad-happy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (user-level defaults, e.g. company branding) merges with project-level config.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"jsonc"},"content":[{"text":"{\n \"project\": {\n \"name\": \"Widget Board\",\n \"number\": \"HW-2024-042\",\n \"revision\": \"1.2\",\n \"company\": \"Acme Electronics\",\n \"author\": \"Jane Smith\",\n \"market\": \"eu\"\n },\n \"reports\": {\n \"classification\": \"Company Confidential\",\n \"documents\": [\n {\"type\": \"hdd\", \"output\": \"HDD-{project}-{rev}\", \"formats\": [\"pdf\", \"docx\"]}\n ],\n \"branding\": {\n \"logo\": \"templates/logo.png\",\n \"header_left\": \"{company}\",\n \"header_right\": \"{number} Rev {rev}\"\n }\n }\n}","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Writing Narratives","type":"text"}]},{"type":"paragraph","content":[{"text":"After generating a scaffold, fill the narrative placeholder sections with engineering prose.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Workflow","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run the context builder to get focused data for each section:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 skills/kidoc/scripts/kidoc_narrative.py \\\n --analysis analysis/schematic.json \\\n --section power_design","type":"text"}]},{"type":"paragraph","content":[{"text":"Or build contexts for all narrative sections at once:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 skills/kidoc/scripts/kidoc_narrative.py \\\n --analysis analysis/schematic.json \\\n --report reports/HDD.md","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For each section, read the context and write prose that:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Explains ","type":"text"},{"text":"why","type":"text","marks":[{"type":"strong"}]},{"text":", not just ","type":"text"},{"text":"what","type":"text","marks":[{"type":"strong"}]},{"text":" — engineering rationale, tradeoffs","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"References specific component values and part numbers","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Uses quantitative language (\"2.3ms hold-up time\" not \"adequate capacitance\")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Flags deviations from datasheet recommendations","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"References SPICE validation results when available","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Replace the italic placeholder ","type":"text"},{"text":"*[...]*","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the markdown with real prose.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"On regeneration, data tables update automatically. Review narratives for consistency with any changed data.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Style Guide","type":"text"}]},{"type":"paragraph","content":[{"text":"Write as a senior EE explaining to a peer:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Lead with the key finding or decision","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Support with specific numbers from the analysis","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Note any risks or deviations","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep paragraphs to 3-5 sentences","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Don't repeat data that's already in tables","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Requirements","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Python 3.9+","type":"text","marks":[{"type":"strong"}]},{"text":" with ","type":"text"},{"text":"python3-venv","type":"text","marks":[{"type":"code_inline"}]},{"text":" (for PDF/DOCX/ODT generation)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"KiCad schematic file","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":", KiCad 6+) — for SVG rendering","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Optional:","type":"text","marks":[{"type":"strong"}]},{"text":" Analysis JSONs are auto-generated from ","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":"; pre-generated JSONs in ","type":"text"},{"text":"analysis/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or the path configured in ","type":"text"},{"text":".kicad-happy.json","type":"text","marks":[{"type":"code_inline"}]},{"text":") are used if present. Generated figures (diagrams, schematic SVGs) are placed in ","type":"text"},{"text":"reports/figures/","type":"text","marks":[{"type":"code_inline"}]},{"text":" for git tracking","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Limitations","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Schematic and PCB renderers support KiCad 6+ formats only (","type":"text"},{"text":".kicad_sch","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":".kicad_pcb","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Narrative sections require the agent or manual authoring — the scaffold provides structure and data, not prose","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"SPICE simulation results require manual simulation setup (not auto-run by scaffold)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PDF vector SVG embedding uses svglib when available; falls back to raster if svglib cannot parse a particular SVG","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Related Skills","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Skill","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Relationship","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"kicad","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Produces schematic/PCB/thermal analysis JSON consumed by scaffolds","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"emc","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Produces EMC analysis JSON for EMC sections","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"spice","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"SPICE simulation results appear in analog design sections","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bom","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BOM data appears in BOM summary sections","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Run the ","type":"text"},{"text":"kicad","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill's analyzers first, then ","type":"text"},{"text":"emc","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"spice","type":"text","marks":[{"type":"code_inline"}]},{"text":" if available. The scaffold auto-runs ","type":"text"},{"text":"kicad","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"emc","type":"text","marks":[{"type":"code_inline"}]},{"text":" analyses when source files are present, so manual pre-analysis is only needed for SPICE.","type":"text"}]}]},"metadata":{"date":"2026-06-05","name":"kidoc","author":"@skillopedia","source":{"stars":464,"repo_name":"kicad-happy","origin_url":"https://github.com/aklofas/kicad-happy/blob/HEAD/skills/kidoc/SKILL.md","repo_owner":"aklofas","body_sha256":"d3444e53cdcc74941e742914e22c216fd84d1c34632b7a5a54db417af1a003a9","cluster_key":"8695176de12f419c6e0b2f86af4f92d9e4c890f3f8c2931ae4131eb054d7feb3","clean_bundle":{"format":"clean-skill-bundle-v1","source":"aklofas/kicad-happy/skills/kidoc/SKILL.md","attachments":[{"id":"b64a868a-74ed-5601-a6b6-b6068a6a2637","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b64a868a-74ed-5601-a6b6-b6068a6a2637/attachment.md","path":"references/document-structure.md","size":3561,"sha256":"bb181f7582a04fc97f80e3a8e2071839cec6ba0ff1fdb763107d69504ab31b8e","contentType":"text/markdown; charset=utf-8"},{"id":"edcc98c7-ffe3-5f36-bb05-754beea6a6ce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/edcc98c7-ffe3-5f36-bb05-754beea6a6ce/attachment.md","path":"references/rendering-notes.md","size":4931,"sha256":"ae4788e6af03981a22deeeadc0e29573884c7deb071b2a669f69179d3167e53d","contentType":"text/markdown; charset=utf-8"},{"id":"141f54d2-f199-56c8-ab91-bdbec872fd8a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/141f54d2-f199-56c8-ab91-bdbec872fd8a/attachment.md","path":"references/report-ce.md","size":3080,"sha256":"07ca29581cac61183f3438895857c535b614360a5ebe259d5b367b4308adf91e","contentType":"text/markdown; charset=utf-8"},{"id":"fb96c14b-3453-5041-877c-8aded80f125a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fb96c14b-3453-5041-877c-8aded80f125a/attachment.md","path":"references/report-design-review.md","size":2535,"sha256":"3087c5f2dc43dd7bcbbca32bffa97a90cf7e080c53e63ab1c3512c012c4fa629","contentType":"text/markdown; charset=utf-8"},{"id":"ec980510-97c6-5e1b-addb-197fddc81a5a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ec980510-97c6-5e1b-addb-197fddc81a5a/attachment.md","path":"references/report-hdd.md","size":5557,"sha256":"b735e46aa383d2dfe80d83a12a06905b79d3769fefa892651215abf224b7305c","contentType":"text/markdown; charset=utf-8"},{"id":"119acccc-cc32-51ac-b360-f38762085306","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/119acccc-cc32-51ac-b360-f38762085306/attachment.md","path":"references/report-icd.md","size":2559,"sha256":"e01e2e27d9173d07942a6cf2032fb0d575757a044c9cb59bbca023aff24d5d00","contentType":"text/markdown; charset=utf-8"},{"id":"e5a33baa-34f6-57e9-96ad-25f99e770a1e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e5a33baa-34f6-57e9-96ad-25f99e770a1e/attachment.md","path":"references/report-manufacturing.md","size":2618,"sha256":"7b9e3828f3e42ed6377bcf2a297bf33383c34a83bff580965411d9220d5d2bc9","contentType":"text/markdown; charset=utf-8"},{"id":"96f3b6db-2fbd-55be-8124-33c03440cb6d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/96f3b6db-2fbd-55be-8124-33c03440cb6d/attachment.md","path":"scripts/README.md","size":5680,"sha256":"1fb6aeff39fff6a1480aea4a41bad651faeb3c38926579a865b8aae4ee37914a","contentType":"text/markdown; charset=utf-8"},{"id":"4b5de115-0002-5308-a3d2-50deaa629392","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4b5de115-0002-5308-a3d2-50deaa629392/attachment.json","path":"scripts/default_specs/ce_technical_file.json","size":2505,"sha256":"d564638de39356e6773f982b5664f6c5e49878a1d5fea3dd3e7223da5886486d","contentType":"application/json; charset=utf-8"},{"id":"a3329eb5-8e72-561b-986c-f0e96b980493","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a3329eb5-8e72-561b-986c-f0e96b980493/attachment.json","path":"scripts/default_specs/design_review.json","size":2190,"sha256":"3fd82702c45771b3b33f2f60aeaed432ef0d16a43a2fa707f7c2a2fd25035ada","contentType":"application/json; charset=utf-8"},{"id":"aa3cbe2f-80ab-598d-a51a-de0a80364e01","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aa3cbe2f-80ab-598d-a51a-de0a80364e01/attachment.json","path":"scripts/default_specs/emc_report.json","size":1303,"sha256":"04f88d5b4795a781cf517bb96d5a45a61ca6aa90019c2c9514ed2ce362f61ead","contentType":"application/json; charset=utf-8"},{"id":"798be039-8d13-5554-aa7b-52ba7c73eb25","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/798be039-8d13-5554-aa7b-52ba7c73eb25/attachment.json","path":"scripts/default_specs/hdd.json","size":3303,"sha256":"56188567fe48f69eb192793b515d73c4907126991dedaff2a13bacc68523bac9","contentType":"application/json; charset=utf-8"},{"id":"954cf1dd-48ac-5968-9d17-e362ec740a24","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/954cf1dd-48ac-5968-9d17-e362ec740a24/attachment.json","path":"scripts/default_specs/icd.json","size":1588,"sha256":"2ab5f6f56e611a1818c51e84aff2f7fd813eb41b356fc1fbf695d6b316771ad3","contentType":"application/json; charset=utf-8"},{"id":"01e3184e-1627-52fb-8eba-e02f032f38ae","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/01e3184e-1627-52fb-8eba-e02f032f38ae/attachment.json","path":"scripts/default_specs/manufacturing.json","size":1805,"sha256":"9b870b4d9629d20b462c5b50d91754f70cd5a5be45df535dafef9f827dcd9a4d","contentType":"application/json; charset=utf-8"},{"id":"26a9f14f-65ba-55f0-8469-afef971c67f3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/26a9f14f-65ba-55f0-8469-afef971c67f3/attachment.json","path":"scripts/default_specs/power_analysis.json","size":1747,"sha256":"642b4a8874aca7164248ea7d99474b4b1adba1ca204495b9e54229b4afb1d7a1","contentType":"application/json; charset=utf-8"},{"id":"7d5f63bd-b8e9-5e49-8528-f4a804ad0745","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7d5f63bd-b8e9-5e49-8528-f4a804ad0745/attachment.json","path":"scripts/default_specs/schematic_review.json","size":1978,"sha256":"1a19e78834f5ac6fd6787e7ec577b503a610107caf46aeb469497c118c149722","contentType":"application/json; charset=utf-8"},{"id":"38654a48-c22e-5222-ac06-dfacc94fd2fb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/38654a48-c22e-5222-ac06-dfacc94fd2fb/attachment.py","path":"scripts/figures/__init__.py","size":629,"sha256":"8b6e7182a62fb3d993a037f96e39e01c6d645d8b0286b0fd7a83e37fd02958e2","contentType":"text/x-python; charset=utf-8"},{"id":"625fcf8d-0b0c-5e7b-8e20-57894eabaa85","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/625fcf8d-0b0c-5e7b-8e20-57894eabaa85/attachment.py","path":"scripts/figures/generators/__init__.py","size":817,"sha256":"6d79b82ace0f54315b0f70b851cd6250671b8d2e7cf124c95924ead64cbaaab3","contentType":"text/x-python; charset=utf-8"},{"id":"952a564c-11bf-5631-9427-6dae73bc26f0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/952a564c-11bf-5631-9427-6dae73bc26f0/attachment.py","path":"scripts/figures/generators/_mpl_common.py","size":1819,"sha256":"8a6ad206563ff823a4808ed4e4743c6cb512b7afe045a9096a55662e44d8cde6","contentType":"text/x-python; charset=utf-8"},{"id":"e87ca1f4-7c55-54f9-b754-c22653917355","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e87ca1f4-7c55-54f9-b754-c22653917355/attachment.py","path":"scripts/figures/generators/architecture/__init__.py","size":11691,"sha256":"b88a142c37ee06d394031399521bd263d5670f3be04374915c8a417d8a08f94c","contentType":"text/x-python; charset=utf-8"},{"id":"a4869b1c-1325-56b5-98f7-69a8fcb7414d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a4869b1c-1325-56b5-98f7-69a8fcb7414d/attachment.py","path":"scripts/figures/generators/bus_topology/__init__.py","size":8134,"sha256":"6e65eeefd5740b62a2cbd3ba1393a28b78d5acb30f96d915fdd7f811e36a1a65","contentType":"text/x-python; charset=utf-8"},{"id":"a356c1d6-5637-5848-9f13-2a9384c1f83b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a356c1d6-5637-5848-9f13-2a9384c1f83b/attachment.py","path":"scripts/figures/generators/emc_severity/__init__.py","size":2995,"sha256":"e6fda4e6e12c8f28056fbebef35778f67c886eeb3dfea3afdba8592d851ed0f0","contentType":"text/x-python; charset=utf-8"},{"id":"865d9db4-a115-5421-b6ec-8bf311a2a3ff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/865d9db4-a115-5421-b6ec-8bf311a2a3ff/attachment.py","path":"scripts/figures/generators/monte_carlo/__init__.py","size":3930,"sha256":"4f2a1a0d9d036dfe700f65fa8a1306a062fa5a2e22125cbcafc95b7c382ed36e","contentType":"text/x-python; charset=utf-8"},{"id":"404a079e-6205-5e59-9721-7e47406a3462","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/404a079e-6205-5e59-9721-7e47406a3462/attachment.py","path":"scripts/figures/generators/pcb_views/__init__.py","size":2648,"sha256":"b282e14d829a1cc36803829aceab3540612f7e8c6109658ffaa089b32b968d3f","contentType":"text/x-python; charset=utf-8"},{"id":"2b9bfd41-5448-52c8-91fe-b5a4817a06f3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2b9bfd41-5448-52c8-91fe-b5a4817a06f3/attachment.py","path":"scripts/figures/generators/pinout/__init__.py","size":22775,"sha256":"bbdae99b8076fc9d64e5fda269ecf2d4bb8e05282b9e8ef12aea4b3e40df73d4","contentType":"text/x-python; charset=utf-8"},{"id":"6dfc4786-6995-52a2-b928-1c3390d55d73","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6dfc4786-6995-52a2-b928-1c3390d55d73/attachment.py","path":"scripts/figures/generators/power_tree/__init__.py","size":28074,"sha256":"85ff32ab70d5500bb76c6ea4d7ceeeeef278f1e56ed806ac31a9d879b9e8cca0","contentType":"text/x-python; charset=utf-8"},{"id":"430fd3d3-eb99-5235-bb64-c058a9dbc32e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/430fd3d3-eb99-5235-bb64-c058a9dbc32e/attachment.py","path":"scripts/figures/generators/schematic_crop/__init__.py","size":3234,"sha256":"9a578c945f4403934274ae1f778c8e19a5d9c2bd512ede00de7353c8f37fda60","contentType":"text/x-python; charset=utf-8"},{"id":"a2e65d59-76b1-5ff4-8c97-3af31622a4d1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a2e65d59-76b1-5ff4-8c97-3af31622a4d1/attachment.py","path":"scripts/figures/generators/schematic_overview/__init__.py","size":3007,"sha256":"8a6252a92dec09b079f94e7d8ba15050ba3ffb3edf52f63bce7293e131a35e1e","contentType":"text/x-python; charset=utf-8"},{"id":"1dd8c536-a305-5c18-973f-263fc4853e48","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1dd8c536-a305-5c18-973f-263fc4853e48/attachment.py","path":"scripts/figures/generators/spice_validation/__init__.py","size":3965,"sha256":"242d1df28042794ad988455125f3e376f4ea32ce7a4636e6c4eb2f8cf9d08edf","contentType":"text/x-python; charset=utf-8"},{"id":"202a4cdf-273d-5b56-a568-86835d4ee25b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/202a4cdf-273d-5b56-a568-86835d4ee25b/attachment.py","path":"scripts/figures/generators/thermal_margin/__init__.py","size":3814,"sha256":"5addd019b6de9b252257e8df2b1e207c9ea2cde76435a69b8d7614c273b86d16","contentType":"text/x-python; charset=utf-8"},{"id":"de76781f-1bb2-5565-8bf5-359936d45f2f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/de76781f-1bb2-5565-8bf5-359936d45f2f/attachment.py","path":"scripts/figures/lib/__init__.py","size":1316,"sha256":"6bc74bbf5d8c04a06b105cd1e8ba1a4fc3a2700bf3ffd630c3c1cf2dc45b6d3b","contentType":"text/x-python; charset=utf-8"},{"id":"e581ee3b-f14d-52ef-88ee-61f7331c20e0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e581ee3b-f14d-52ef-88ee-61f7331c20e0/attachment.py","path":"scripts/figures/lib/analysis_helpers.py","size":992,"sha256":"71f5543f0b2a52e12fcf1a5695190bb0d2953c554a070176c441c1710f20e153","contentType":"text/x-python; charset=utf-8"},{"id":"dfeb40bb-eb7a-5fea-817a-2bbdd78b9d42","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dfeb40bb-eb7a-5fea-817a-2bbdd78b9d42/attachment.py","path":"scripts/figures/lib/color_theme.py","size":4505,"sha256":"afce578215f601eb832e2561695bcaa99220f194b16f5eb5dd25f72e3263cc26","contentType":"text/x-python; charset=utf-8"},{"id":"b6cf7652-f96b-5f1a-a34f-d6457b8c114e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b6cf7652-f96b-5f1a-a34f-d6457b8c114e/attachment.py","path":"scripts/figures/lib/layer_presets.py","size":6061,"sha256":"6ec47190557683a8fc7072e0b64403cdae27603d7092cabb7bc7a94495c84639","contentType":"text/x-python; charset=utf-8"},{"id":"a4588886-7bb5-5d66-a872-15d823785439","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a4588886-7bb5-5d66-a872-15d823785439/attachment.py","path":"scripts/figures/lib/schematic_constants.py","size":1517,"sha256":"9c6818d5496daba9bd9dc31b0a7cbef6017ff80460f306553ca22387401023ed","contentType":"text/x-python; charset=utf-8"},{"id":"bb9d61db-5966-5f47-9a1c-b7abd46e958c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bb9d61db-5966-5f47-9a1c-b7abd46e958c/attachment.py","path":"scripts/figures/lib/styles.py","size":16071,"sha256":"e7f22350f865ad172a21b423e2c98ce875bb91cb71c293e90960c53edaa2aa1c","contentType":"text/x-python; charset=utf-8"},{"id":"3a250564-8226-5a83-b526-d858a98aaff7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3a250564-8226-5a83-b526-d858a98aaff7/attachment.py","path":"scripts/figures/lib/svg_builder.py","size":18650,"sha256":"6a13fbdc63faa89e4f1fe11973e2c74b2a21e92564d805ab200d1fd7c961474f","contentType":"text/x-python; charset=utf-8"},{"id":"632463db-1991-583b-b7df-ff229f4c8549","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/632463db-1991-583b-b7df-ff229f4c8549/attachment.py","path":"scripts/figures/lib/svg_embed.py","size":16512,"sha256":"05b5213eee9994eefe67a5cf00cb1905435ae05c3db479584b166f2a5a000eb6","contentType":"text/x-python; charset=utf-8"},{"id":"7ce4f3ca-768f-5905-9c0f-5f00b39847e9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7ce4f3ca-768f-5905-9c0f-5f00b39847e9/attachment.py","path":"scripts/figures/lib/svg_to_png.py","size":10877,"sha256":"bd887c0271b1f5892434745c87398cd76b7be54191f8b403e5e4d6e6a6eea7d6","contentType":"text/x-python; charset=utf-8"},{"id":"f367f5a3-3253-59e1-9b18-88c613137c09","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f367f5a3-3253-59e1-9b18-88c613137c09/attachment.py","path":"scripts/figures/lib/theme.py","size":8070,"sha256":"d1bb9e374448d2d28adb6f0d353a4a80ccbd42f3f5bd1b13d993493b2462122e","contentType":"text/x-python; charset=utf-8"},{"id":"1e1e8899-309d-53e3-8b8e-d6bc61b2829d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1e1e8899-309d-53e3-8b8e-d6bc61b2829d/attachment.py","path":"scripts/figures/registry.py","size":2400,"sha256":"003119f89a106c74a91b3067fb2bd73fdd55f27b082a1bfb25d947a61e4ca6d8","contentType":"text/x-python; charset=utf-8"},{"id":"a7f3a9e9-35f4-504b-94c4-0206554c34c7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a7f3a9e9-35f4-504b-94c4-0206554c34c7/attachment.py","path":"scripts/figures/renderers/__init__.py","size":655,"sha256":"fe6639086598d7d3a1b4943358330e15a39bd05cfa827aedb858abd3cebe5f8f","contentType":"text/x-python; charset=utf-8"},{"id":"aa59e4e4-1280-52d6-9261-0be7825b08c9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aa59e4e4-1280-52d6-9261-0be7825b08c9/attachment.py","path":"scripts/figures/renderers/_path_setup.py","size":1136,"sha256":"411f05c88832ef0844b0b5eabd3134987bc1f669ca64301bd58ed579dbf53817","contentType":"text/x-python; charset=utf-8"},{"id":"293fcd6b-f2ba-5e85-a9fc-e9a19bf7c31d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/293fcd6b-f2ba-5e85-a9fc-e9a19bf7c31d/attachment.py","path":"scripts/figures/renderers/pcb.py","size":26764,"sha256":"d37708857eeee9d7decf5c7d22483cc946dfbcc252fe853251dcd332b6f1d3c3","contentType":"text/x-python; charset=utf-8"},{"id":"b6c05bbc-16e1-5be0-82c9-2d10f7872d99","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b6c05bbc-16e1-5be0-82c9-2d10f7872d99/attachment.py","path":"scripts/figures/renderers/pcb_graphics.py","size":27130,"sha256":"f388196c5a30405b4f1747314808629f19a3e967a370272ed79cddbe8c26ff3a","contentType":"text/x-python; charset=utf-8"},{"id":"aebd365e-b2e3-5852-abad-04ac6c4f0aa2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aebd365e-b2e3-5852-abad-04ac6c4f0aa2/attachment.py","path":"scripts/figures/renderers/sch_graphics.py","size":14163,"sha256":"45e39bf6450899860c0a0d90a7886a4779add12aea75d499c85e241cee03faf7","contentType":"text/x-python; charset=utf-8"},{"id":"e9ef9928-f3e5-575c-9898-92c5f10b3bc0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e9ef9928-f3e5-575c-9898-92c5f10b3bc0/attachment.py","path":"scripts/figures/renderers/schematic.py","size":61553,"sha256":"911450514be0334e6a14f5dc576020e9cdfb43be7443dd56a014f601f60414f0","contentType":"text/x-python; charset=utf-8"},{"id":"f8503fd8-a08f-5f9d-9951-9aab4ea94896","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f8503fd8-a08f-5f9d-9951-9aab4ea94896/attachment.py","path":"scripts/figures/runner.py","size":5841,"sha256":"cb750833f4536154d18f093d78c8b81449c26eb8e6647c52528c1585bc6838cc","contentType":"text/x-python; charset=utf-8"},{"id":"7c0c5b91-84d0-539d-9fdf-ecdeeeaadc1e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7c0c5b91-84d0-539d-9fdf-ecdeeeaadc1e/attachment.py","path":"scripts/kicad_cli.py","size":11206,"sha256":"390d0f2e1a55182e805494b5f30b4b16cd76f2a3f8bae7487854ad6392a72948","contentType":"text/x-python; charset=utf-8"},{"id":"56c61313-4e2a-5c56-978d-d8186297ed79","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/56c61313-4e2a-5c56-978d-d8186297ed79/attachment.py","path":"scripts/kidoc_datasheet.py","size":37604,"sha256":"fa403fe6f01b47f3c7705c0d50a788eea7e305d1dcca3437dbfefe71d7d4baae","contentType":"text/x-python; charset=utf-8"},{"id":"d65e32d8-3739-5832-a522-b311067af62a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d65e32d8-3739-5832-a522-b311067af62a/attachment.py","path":"scripts/kidoc_diagrams.py","size":2929,"sha256":"769874d9d6dddc36b57d886d94308bdf1a945fe62be6b214ae88b1f817fb9daf","contentType":"text/x-python; charset=utf-8"},{"id":"547ffa06-47a0-5565-8c0a-d29e92655044","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/547ffa06-47a0-5565-8c0a-d29e92655044/attachment.py","path":"scripts/kidoc_docx.py","size":9950,"sha256":"10f12d682815f6972f1532f0f0b83a225e148f2ce4cbe6d5cfcf165ab5122196","contentType":"text/x-python; charset=utf-8"},{"id":"60fb7066-172a-57a9-9b39-898b07ed18a8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/60fb7066-172a-57a9-9b39-898b07ed18a8/attachment.py","path":"scripts/kidoc_generate.py","size":8561,"sha256":"811885d14d09fc2060b650d031a08a006dc034956f334ca53da003aba29da332","contentType":"text/x-python; charset=utf-8"},{"id":"91a34a4e-5639-5590-b3a3-e8d4df0fdaef","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/91a34a4e-5639-5590-b3a3-e8d4df0fdaef/attachment.py","path":"scripts/kidoc_html.py","size":11139,"sha256":"3befd444fc8b0199fda6ae1c68a4290dad0b17ffc6d3944fb48640b06187d93c","contentType":"text/x-python; charset=utf-8"},{"id":"3080e66f-aee5-53e8-9d9a-6c37aa5629f5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3080e66f-aee5-53e8-9d9a-6c37aa5629f5/attachment.py","path":"scripts/kidoc_md_parser.py","size":9501,"sha256":"05edb7f3e2dddd08cd46d85b418a9802dd74441a3c7c7b77fc535d52d1c73a60","contentType":"text/x-python; charset=utf-8"},{"id":"a913287a-5a51-5d79-9c73-29cf6c4bc6db","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a913287a-5a51-5d79-9c73-29cf6c4bc6db/attachment.py","path":"scripts/kidoc_narrative.py","size":15102,"sha256":"7e55ba3d5fd0c7bf8041f37f2dc54ea51af383894319cbeee22f14b73c8453aa","contentType":"text/x-python; charset=utf-8"},{"id":"21263b12-f3bf-5531-857b-d40c58c3fd9a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/21263b12-f3bf-5531-857b-d40c58c3fd9a/attachment.py","path":"scripts/kidoc_narrative_augment.py","size":5510,"sha256":"da77170c2d32857761a9eccb4fdb85229c95f0cd02ef6ecfbd41c27d804d9169","contentType":"text/x-python; charset=utf-8"},{"id":"922ed896-8782-5344-bcd8-6ff89f013e67","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/922ed896-8782-5344-bcd8-6ff89f013e67/attachment.py","path":"scripts/kidoc_narrative_config.py","size":10162,"sha256":"d8d7718f897fe18bf13cd30ba22c8e7e559ef845afbd1e4ed89e5a60ed02b349","contentType":"text/x-python; charset=utf-8"},{"id":"432e71e5-d598-54f6-99f7-7348a335b938","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/432e71e5-d598-54f6-99f7-7348a335b938/attachment.py","path":"scripts/kidoc_narrative_extractors.py","size":22639,"sha256":"bbd9bc0416511ffa4d10a170700c01a5f7f32deee0921c461deb5a1dd369132e","contentType":"text/x-python; charset=utf-8"},{"id":"64e426fc-da06-5150-952f-3acc3f18af0c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/64e426fc-da06-5150-952f-3acc3f18af0c/attachment.py","path":"scripts/kidoc_odt.py","size":13162,"sha256":"42e1e36929605242df347b0f44d42250ee97c79e21f4e48090817848968a4588","contentType":"text/x-python; charset=utf-8"},{"id":"f1e28695-d3f5-5685-8f3d-2baee9987ae1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f1e28695-d3f5-5685-8f3d-2baee9987ae1/attachment.py","path":"scripts/kidoc_orchestrator.py","size":8041,"sha256":"4d9e414054e821966bb37db93c27bc9e314dc6b025222f98cb8e478acb06848d","contentType":"text/x-python; charset=utf-8"},{"id":"9821bb4c-5de7-599d-910d-1302c5666768","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9821bb4c-5de7-599d-910d-1302c5666768/attachment.py","path":"scripts/kidoc_pdf.py","size":31290,"sha256":"6a7ca6ba13ea944ac4328f168533b114d1a3a5649ccdc38310b079db334ccbf6","contentType":"text/x-python; charset=utf-8"},{"id":"f60896b1-9f38-592b-b092-c9ade9cbbc46","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f60896b1-9f38-592b-b092-c9ade9cbbc46/attachment.py","path":"scripts/kidoc_raster.py","size":1155,"sha256":"c9d3405150de70e0fdb3aa44ec2f1e10ee310d22da21b0024dfacd10476ed66f","contentType":"text/x-python; charset=utf-8"},{"id":"4c1e4696-e617-5a6f-b4d8-05880784ef6a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4c1e4696-e617-5a6f-b4d8-05880784ef6a/attachment.py","path":"scripts/kidoc_scaffold.py","size":26627,"sha256":"1c538f9394ac8c5e08a159b0cffd2c1920ce1fcc014a52827f77f1939033da8b","contentType":"text/x-python; charset=utf-8"},{"id":"c7efde54-32d0-59b8-bdfc-d610dfad0d1c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c7efde54-32d0-59b8-bdfc-d610dfad0d1c/attachment.py","path":"scripts/kidoc_sections.py","size":46997,"sha256":"4ac5cdd9a48c69d71967f07db085dc41e6ea0a475234ed69879f8b9b6c7e83a7","contentType":"text/x-python; charset=utf-8"},{"id":"3d3106b9-f99c-5bd7-8bb9-ec9792e3cc54","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3d3106b9-f99c-5bd7-8bb9-ec9792e3cc54/attachment.py","path":"scripts/kidoc_spec.py","size":10242,"sha256":"55115fa369f5b4a8fe31301efe2e3a961e229ac3f03a0d34875edd697043537c","contentType":"text/x-python; charset=utf-8"},{"id":"0fd2e973-f215-56bc-bc54-614a87c83b7e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0fd2e973-f215-56bc-bc54-614a87c83b7e/attachment.py","path":"scripts/kidoc_tables.py","size":3767,"sha256":"ef667b72f174c3206558a8107a136a4a006a853625f71588e4d965370c564efe","contentType":"text/x-python; charset=utf-8"},{"id":"0d92f356-fe2d-5193-a99c-7f0bc292e3cb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0d92f356-fe2d-5193-a99c-7f0bc292e3cb/attachment.py","path":"scripts/kidoc_templates.py","size":4063,"sha256":"6bd2d25813361379a5dcb94618473f69fd127ebaa6ae6e9571914f3c6b08f1d8","contentType":"text/x-python; charset=utf-8"},{"id":"47257ab3-43d9-518e-afe3-6a1b722d8cf3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/47257ab3-43d9-518e-afe3-6a1b722d8cf3/attachment.py","path":"scripts/kidoc_venv.py","size":3005,"sha256":"8acf27be31334ebedaa7682ed9505bbfd7952432bdb8b0148ac5490c839ad0f8","contentType":"text/x-python; charset=utf-8"},{"id":"567aa9e2-e0e7-575a-8797-46621f3170a0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/567aa9e2-e0e7-575a-8797-46621f3170a0/attachment.txt","path":"scripts/requirements.txt","size":103,"sha256":"d1c11e791dd7880cf8b92f3923659111ed587d52258bb2f6e0cf251823fffade","contentType":"text/plain; charset=utf-8"}],"bundle_sha256":"8aa54e0e4d4466a6a221185a94f16ac9f788e5d4286cd90c259bca3b24dbc9ff","attachment_count":70,"text_attachments":70,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/kidoc/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"documents-office","category_label":"Documents"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"documents-office","import_tag":"clean-skills-v1","description":"Generate professional engineering documentation from KiCad projects — Hardware Design Descriptions (HDD), CE Technical Files, Interface Control Documents (ICD), Design Review Packages, and Manufacturing Transfer Packages. Auto-runs schematic, PCB, EMC, and thermal analyses; renders schematic and PCB SVGs with subsystem cropping, focus dimming, net highlighting, and pin-net annotation; generates power tree, bus topology, and architecture block diagrams. Produces styled PDF with cover pages, TOC, and vector SVG embedding. Markdown source of truth — human-editable, version-controllable. Use for \"generate documentation\", \"create report\", \"HDD\", \"CE technical file\", \"design review package\", \"ICD\", \"render schematic\", \"render layout\", \"generate block diagram\", \"manufacturing package\", \"generate PDF\", or \"custom report\"."}},"renderedAt":1782980627000}

kidoc — Engineering Documentation Skill Generate professional engineering documentation from KiCad project files. Quick Start One command generates the full scaffold — analyses, diagrams, renders, and markdown are all produced automatically: This auto-detects and files, runs schematic/PCB/EMC/thermal analyses, generates block diagrams and schematic SVG renders, and produces a structured markdown scaffold with pre-filled data tables and narrative placeholders. To produce a PDF: Creates automatically on first run (PDF/DOCX/ODT only — HTML is zero-dep). Workflow 1. Generate scaffold — auto-runs…