import re
import os
from dataclasses import dataclass, field
from pathlib import Path
# ─────────────────────────────────────────────
# Structures de données
# ─────────────────────────────────────────────
@dataclass
class Variant:
name: str
in_bom: bool = True
dnp: bool = False
@dataclass
class Component:
reference: str
value: str
footprint: str
description: str
datasheet: str
dnp: bool
variants: list[Variant] = field(default_factory=list)
@dataclass
class TestPoint:
reference: str
description: str
@dataclass
class HierarchicalLabel:
name: str
shape: str
description: str
@dataclass
class Sheet:
name: str
filename: str
components: list[Component] = field(default_factory=list)
labels: list[HierarchicalLabel] = field(default_factory=list)
test_points: list[TestPoint] = field(default_factory=list)
svg_content: str = "" # ← contenu SVG inline, si trouvé
# ─────────────────────────────────────────────
# Utilitaires S-expression
# ─────────────────────────────────────────────
def extract_block(content: str, start: int) -> str:
depth = 0
for i, ch in enumerate(content[start:], start):
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return content[start:i + 1]
return content[start:]
def find_all_blocks(content: str, keyword: str) -> list[str]:
blocks = []
i = 0
search = f"({keyword}"
while True:
start = content.find(search, i)
if start == -1:
break
next_ch = content[start + len(search)] if start + len(search) < len(content) else ""
if next_ch in (" ", "\t", "\n", "\r"):
blocks.append(extract_block(content, start))
i = start + 1
return blocks
def get_field(block: str, name: str) -> str:
m = re.compile(
rf'\(property\s+"{re.escape(name)}"\s+"([^"]*)"', re.IGNORECASE
).search(block)
return m.group(1).strip() if m else ""
def is_test_point(reference: str) -> bool:
return bool(re.match(r'^TP\d+$', reference, re.IGNORECASE))
# ─────────────────────────────────────────────
# Chargement SVG hiérarchique
# Pattern attendu : yyy-NomDuModule.svg
def find_svg_for_sheet(sheet_name: str, search_dir: Path) -> str:
"""
Cherche un fichier SVG dont le nom se termine par -<sheet_name>.svg
(insensible à la casse, le préfixe yyy peut être quelconque).
La recherche se fait d'abord dans _doc/svg, puis dans le dossier passé en paramètre.
Retourne le contenu SVG nettoyé (sans déclaration XML) ou "".
"""
pattern = re.compile(rf'^.+-{re.escape(sheet_name)}\.svg$', re.IGNORECASE)
# Dossiers à explorer dans l'ordre de priorité
folders_to_check = []
# Ajouter le dossier _doc/svg s'il existe
doc_svg_dir = Path("_doc/svg")
if doc_svg_dir.exists() and doc_svg_dir.is_dir():
folders_to_check.append(doc_svg_dir)
# Ajouter le dossier passé en paramètre
if search_dir.exists() and search_dir.is_dir():
folders_to_check.append(search_dir)
# Parcourir les dossiers
for folder in folders_to_check:
for candidate in folder.iterdir():
if candidate.is_file() and pattern.match(candidate.name):
raw = candidate.read_text(encoding="utf-8", errors="replace")
# Supprimer la déclaration XML et le DOCTYPE pour l'inline
raw = re.sub(r'<\?xml[^?]*\?>', '', raw)
raw = re.sub(r'<!DOCTYPE[^>]*>', '', raw)
# Retirer width/height fixes sur la balise <svg> pour que le SVG soit responsive
raw = re.sub(
r'(<svg\b[^>]*?)\s+width="[^"]*"', r'\1', raw, count=1
)
raw = re.sub(
r'(<svg\b[^>]*?)\s+height="[^"]*"', r'\1', raw, count=1
)
# Ajouter viewBox si absent et injecter class
if 'class=' not in raw[:200]:
raw = raw.replace('<svg', '<svg class="sheet-svg"', 1)
# Forcer un fond blanc pour le SVG
if 'style=' not in raw[:200]:
raw = raw.replace('<svg', '<svg style="background-color: white;"', 1)
return raw.strip()
return ""
# ─────────────────────────────────────────────
# Parsers
# ─────────────────────────────────────────────
SHAPE_TO_DIRECTION = {
"input": "Input",
"output": "Output",
"passive": "Passive",
"bidirectional": "Bidirectional",
"tri_state": "Tri-State",
}
SHAPE_COLOR = {
"input": "#d0e8ff",
"output": "#d4f7d4",
"passive": "#f0f0f0",
"bidirectional": "#fff3cd",
"tri_state": "#f8d7da",
}
SHAPE_ICON = {
"input": "→",
"output": "←",
"passive": "—",
"bidirectional": "⇄",
"tri_state": "↕",
}
def parse_hierarchical_labels(content: str) -> list[HierarchicalLabel]:
labels = []
for block in find_all_blocks(content, "hierarchical_label"):
name_m = re.search(r'\(hierarchical_label\s+"([^"]+)"', block)
shape_m = re.search(r'\(shape\s+(\w+)\)', block)
desc_m = re.search(r'\(property\s+"doc"\s+"([^"]*)"', block)
if not name_m or not shape_m:
continue
raw_desc = desc_m.group(1).strip() if desc_m else ""
labels.append(HierarchicalLabel(
name=name_m.group(1),
shape=shape_m.group(1),
description=raw_desc if raw_desc else "pas de doc",
))
return sorted(labels, key=lambda l: l.name)
def parse_variants(symbol_block: str) -> list[Variant]:
variants = []
for vblock in find_all_blocks(symbol_block, "variant"):
name_m = re.search(r'\(name\s+"([^"]+)"\)', vblock)
if not name_m:
continue
name = name_m.group(1)
in_bom = not bool(re.search(r'\(in_bom\s+no\)', vblock))
dnp = bool(re.search(r'\(dnp\s+yes\)', vblock))
variants.append(Variant(name=name, in_bom=in_bom, dnp=dnp))
return variants
def parse_lib_symbols(content: str) -> dict[str, dict]:
lib_info = {}
start = content.find("(lib_symbols")
if start == -1:
return lib_info
lib_block = extract_block(content, start)
for sym_block in find_all_blocks(lib_block, "symbol"):
name_m = re.search(r'\(symbol\s+"([^"]+)"', sym_block)
if not name_m:
continue
lib_id = name_m.group(1)
lib_info[lib_id] = {
"description": get_field(sym_block, "doc"),
"datasheet": get_field(sym_block, "Datasheet"),
}
return lib_info
def parse_components_and_testpoints(
content: str, lib_info: dict
) -> tuple[list[Component], list[TestPoint]]:
components = []
test_points_raw = []
for sym_block in find_all_blocks(content, "symbol"):
lib_id_m = re.search(r'\(lib_id\s+"([^"]+)"\)', sym_block)
if not lib_id_m:
continue
lib_id = lib_id_m.group(1)
ref_m = re.search(r'\(reference\s+"([^"]+)"\)', sym_block)
reference = ref_m.group(1) if ref_m else get_field(sym_block, "Reference")
if not reference or reference.endswith("?") or reference.startswith("#"):
continue
value = get_field(sym_block, "Value")
footprint = get_field(sym_block, "Footprint")
description = get_field(sym_block, "doc")
datasheet = get_field(sym_block, "Datasheet")
dnp = bool(re.search(r'\(dnp\s+yes\)', sym_block))
if lib_id in lib_info:
if not description:
description = lib_info[lib_id].get("description", "")
if not datasheet:
datasheet = lib_info[lib_id].get("datasheet", "")
description = description or "—"
datasheet = datasheet or "—"
if is_test_point(reference):
test_points_raw.append(TestPoint(
reference=reference,
description=description,
))
else:
variants = parse_variants(sym_block)
components.append(Component(
reference=reference,
value=value,
footprint=footprint,
description=description,
datasheet=datasheet,
dnp=dnp,
variants=variants,
))
seen: dict[str, Component] = {}
for comp in components:
if comp.reference not in seen:
seen[comp.reference] = comp
else:
existing_names = {v.name for v in seen[comp.reference].variants}
for v in comp.variants:
if v.name not in existing_names:
seen[comp.reference].variants.append(v)
seen_tp: dict[str, TestPoint] = {}
for tp in test_points_raw:
seen_tp.setdefault(tp.reference, tp)
def sort_key_comp(c):
prefix = re.sub(r'\d', '', c.reference)
num_m = re.search(r'\d+', c.reference)
return (prefix, int(num_m.group()) if num_m else 0)
def sort_key_tp(t):
num_m = re.search(r'\d+', t.reference)
return int(num_m.group()) if num_m else 0
return (
sorted(seen.values(), key=sort_key_comp),
sorted(seen_tp.values(), key=sort_key_tp),
)
def collect_sheets(sheets_dir: Path, root_sch: Path) -> list[tuple[str, Path]]:
sheets = []
for path in sorted(sheets_dir.rglob("*.kicad_sch")):
if path.resolve() == root_sch.resolve():
continue
sheets.append((path.stem, path))
return sheets
# ─────────────────────────────────────────────
# Calcul taux de couverture de documentation
# ─────────────────────────────────────────────
def compute_doc_coverage(sheets: list[Sheet]) -> dict:
global_comp_total = 0
global_comp_documented = 0
global_sig_total = 0
global_sig_documented = 0
per_sheet = []
for sheet in sheets:
comp_total = len(sheet.components)
comp_doc = sum(1 for c in sheet.components if c.description not in ("—", ""))
sig_total = len(sheet.labels)
sig_doc = sum(1 for l in sheet.labels if l.description != "pas de doc")
total = comp_total + sig_total
documented = comp_doc + sig_doc
pct = round(100 * documented / total) if total else 100
per_sheet.append({
"name": sheet.name,
"comp_total": comp_total,
"comp_doc": comp_doc,
"sig_total": sig_total,
"sig_doc": sig_doc,
"total": total,
"documented": documented,
"pct": pct,
})
global_comp_total += comp_total
global_comp_documented += comp_doc
global_sig_total += sig_total
global_sig_documented += sig_doc
global_total = global_comp_total + global_sig_total
global_documented = global_comp_documented + global_sig_documented
global_pct = round(100 * global_documented / global_total) if global_total else 100
return {
"global_pct": global_pct,
"global_total": global_total,
"global_documented": global_documented,
"global_comp_total": global_comp_total,
"global_comp_doc": global_comp_documented,
"global_sig_total": global_sig_total,
"global_sig_doc": global_sig_documented,
"per_sheet": per_sheet,
}
# ─────────────────────────────────────────────
# Rendu HTML
# ─────────────────────────────────────────────
def format_variants(variants: list[Variant]) -> str:
if not variants:
return "—"
parts = []
for v in variants:
flags = []
if not v.in_bom:
flags.append("hors BOM")
if v.dnp:
flags.append("DNP")
label = v.name
if flags:
label += f" <small>({'|'.join(flags)})</small>"
parts.append(label)
return ", ".join(parts)
def sheet_to_html(sheet: Sheet) -> str:
anchor = sheet.name.lower().replace(" ", "-")
# ── SVG inline (ouvert par défaut) ──────────────────────────────────────────
svg_block = ""
if sheet.svg_content:
svg_id = f"svg-toggle-{anchor}"
svg_block = f"""
<details class="svg-accordion" open>
<summary class="svg-summary">
<span class="svg-icon">◈</span>
Schéma hiérarchique
<span class="svg-badge">SVG</span>
</summary>
<div class="svg-wrap">
{sheet.svg_content}
</div>
</details>
"""
html = f"""
<section>
<h2>{sheet.name} <span class="filename">{sheet.filename}</span></h2>
{svg_block}
"""
if sheet.labels:
html += """
<h3 class="section-label">Signaux hiérarchiques</h3>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Direction</th>
<th>Description</th>
</tr>
</thead>
<tbody>
"""
for lbl in sheet.labels:
color = SHAPE_COLOR.get(lbl.shape, "#ffffff")
direction = SHAPE_TO_DIRECTION.get(lbl.shape, lbl.shape)
icon = SHAPE_ICON.get(lbl.shape, "")
no_doc = ' class="no-doc"' if lbl.description == "pas de doc" else ""
html += f"""
<tr>
<td><code>{lbl.name}</code></td>
<td><span class="badge" style="background:{color}">{icon} {direction}</span></td>
<td{no_doc}>{lbl.description}</td>
</tr>
"""
html += " </tbody>\n </table>\n"
if sheet.components:
html += """
<h3 class="section-label">Composants</h3>
<table>
<thead>
<tr>
<th>Réf.</th>
<th>Valeur</th>
<th>Description</th>
<th>Boîtier</th>
<th>Variantes</th>
</tr>
</thead>
<tbody>
"""
for comp in sheet.components:
fp = comp.footprint.split(":")[-1] if ":" in comp.footprint else comp.footprint
variants = format_variants(comp.variants)
dnp_cls = ' class="dnp"' if comp.dnp else ""
dnp_tag = ' <span class="dnp-badge">DNP</span>' if comp.dnp else ""
html += f"""
<tr{dnp_cls}>
<td><code>{comp.reference}{dnp_tag}</code></td>
<td><strong>{comp.value}</strong></td>
<td>{comp.description}</td>
<td><small>{fp}</small></td>
<td>{variants}</td>
</tr>
"""
html += " </tbody>\n </table>\n"
else:
html += " <p class='empty'>Aucun composant trouvé.</p>\n"
html += " </section>\n"
return html
def global_test_points_section_html(sheets: list[Sheet]) -> str:
all_tps: list[tuple[str, TestPoint]] = []
for sheet in sheets:
for tp in sheet.test_points:
all_tps.append((sheet.name, tp))
if not all_tps:
return ""
all_tps.sort(key=lambda x: int(re.search(r'\d+', x[1].reference).group())
if re.search(r'\d+', x[1].reference) else 0)
html = """
<section id="recap-test-points">
<h2>Récapitulatif — Points de test <span class="filename">toutes feuilles</span></h2>
<table>
<thead>
<tr>
<th>Réf.</th>
<th>Description</th>
</tr>
</thead>
<tbody>
"""
for _sheet_name, tp in all_tps:
no_desc = ' class="no-doc"' if tp.description == "—" else ""
html += f"""
<tr class="tp-row">
<td><code class="tp-ref">{tp.reference}</code></td>
<td{no_desc}>{tp.description}</td>
</tr>
"""
html += " </tbody>\n </table>\n </section>\n"
return html
def coverage_bar_color(pct: int) -> str:
if pct >= 80:
return "#22c55e"
elif pct >= 50:
return "#f59e0b"
else:
return "#ef4444"
# CSS avec thème clair par défaut et support du thème sombre
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,300;14..32,400;14..32,500;14..32,600;14..32,700&family=JetBrains+Mono:wght@400;500&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
/* Variables du thème clair (par défaut) */
:root {
--bg-primary: #f5f7fb;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--sidebar-bg: #1a1a2e;
--sidebar-text: #e8edff;
--sidebar-text-secondary: #8b95c9;
--sidebar-accent: #6b8cff;
--primary: #1a3a5c;
--accent: #4f46e5;
--text-primary: #1e293b;
--text-secondary: #475569;
--text-muted: #94a3b8;
--text-white: #1e293b;
--border: #e2e8f0;
--border-hover: #cbd5e1;
--accent-primary: #4f46e5;
--accent-secondary: #818cf8;
--accent-glow: rgba(79, 70, 229, 0.1);
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.1);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--info: #3b82f6;
--info-bg: rgba(59, 130, 246, 0.1);
--gold: #fbbf24;
--gold-bg: rgba(251, 191, 36, 0.1);
--step: #8b5cf6;
--step-bg: rgba(139, 92, 246, 0.1);
--tp-color: #ea580c;
--danger: #ef4444;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Monaco, monospace;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-hover: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Thème sombre */
body.dark-theme {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--sidebar-bg: #020617;
--sidebar-text: #e2e8f0;
--sidebar-text-secondary: #64748b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-muted: #64748b;
--text-white: #f1f5f9;
--border: #334155;
--border-hover: #475569;
--accent-primary: #818cf8;
--accent-secondary: #a5b4fc;
--accent-glow: rgba(129, 140, 248, 0.15);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-hover: 0 4px 6px rgba(0, 0, 0, 0.4);
}
body {
font-family: var(--font-sans);
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* ── BOUTON THÈME ── */
.theme-toggle {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 50px;
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
font-family: var(--font-sans);
}
.theme-toggle:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* ── SIDEBAR ─────────────────────────────── */
#sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
width: 280px;
background: var(--sidebar-bg);
color: var(--sidebar-text);
display: flex;
flex-direction: column;
overflow-y: auto;
z-index: 100;
box-shadow: 2px 0 12px rgba(0,0,0,0.1);
transition: background-color 0.3s ease;
}
#sidebar-header {
padding: 1.5rem 1.2rem 1rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: linear-gradient(135deg, var(--sidebar-bg) 0%, rgba(255,255,255,0.05) 100%);
}
#sidebar-header h1 {
font-size: 1rem;
font-weight: 700;
color: #fff;
letter-spacing: 0.02em;
line-height: 1.3;
}
#sidebar-header p {
font-size: 0.7rem;
color: var(--sidebar-text-secondary);
margin-top: 0.3rem;
font-family: var(--font-mono);
}
/* ── COVERAGE WIDGET ── */
#coverage-widget {
padding: 0.85rem 1.2rem;
border-bottom: 1px solid rgba(255,255,255,0.07);
}
#coverage-widget .cov-title {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--sidebar-text-secondary);
margin-bottom: 0.5rem;
font-weight: 600;
}
.cov-global-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.45rem;
}
.cov-pct-label {
font-family: var(--font-mono);
font-size: 1.3rem;
font-weight: 600;
color: #fff;
line-height: 1;
}
.cov-pct-label.warn { color: #fbbf24; }
.cov-pct-label.danger { color: #f87171; }
.cov-sub {
font-size: 0.65rem;
color: var(--sidebar-text-secondary);
text-align: right;
line-height: 1.5;
}
.cov-bar-wrap {
background: rgba(255,255,255,0.07);
border-radius: 3px;
height: 4px;
overflow: hidden;
}
.cov-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
/* ── BOUTON RETOUR ── */
.back-button {
margin: 1rem 1.2rem;
padding: 0.6rem 1rem;
background: rgba(107, 140, 255, 0.15);
border: 1px solid var(--accent);
border-radius: 10px;
text-decoration: none;
color: var(--accent);
font-size: 0.8rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.25s;
}
.back-button:hover {
background: rgba(107, 140, 255, 0.25);
transform: translateX(-4px);
}
/* ── NAV LINKS ── */
#sidebar-nav {
padding: 0.6rem 0;
flex: 1;
}
#sidebar-nav .nav-section-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--sidebar-text-secondary);
padding: 0.5rem 1.2rem 0.25rem;
font-weight: 600;
}
.nav-sheet-link {
display: block;
padding: 0.4rem 1.2rem 0.45rem;
text-decoration: none;
color: var(--sidebar-text);
font-size: 0.82rem;
transition: background 0.15s, color 0.15s;
border-left: 3px solid transparent;
position: relative;
}
.nav-sheet-link:hover {
background: rgba(107,140,255,0.08);
color: #fff;
border-left-color: var(--sidebar-accent);
}
.nav-sheet-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem;
}
.nav-sheet-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-sheet-pct {
font-family: var(--font-mono);
font-size: 0.65rem;
opacity: 0.55;
flex-shrink: 0;
}
.nav-sheet-bar {
height: 2px;
border-radius: 2px;
margin-top: 0.25rem;
background: rgba(255,255,255,0.05);
overflow: hidden;
}
.nav-sheet-bar-fill {
height: 100%;
border-radius: 2px;
opacity: 0.6;
}
#sidebar-nav a.tp-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 1.2rem;
text-decoration: none;
color: #fb923c;
font-size: 0.82rem;
transition: background 0.15s, color 0.15s;
border-left: 3px solid transparent;
}
#sidebar-nav a.tp-link:hover {
background: rgba(249,115,22,0.12);
color: #fdba74;
border-left-color: var(--tp-color);
}
#sidebar-nav a .nav-icon {
font-size: 0.75rem;
opacity: 0.7;
width: 16px;
text-align: center;
}
/* ── MAIN CONTENT ── */
#main {
margin-left: 280px;
flex: 1;
padding: 2rem 2.5rem;
max-width: calc(100vw - 280px);
}
section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem 2rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow);
transition: all 0.3s ease;
}
section:hover {
border-color: var(--border-hover);
box-shadow: var(--shadow-hover);
}
section h2 {
font-size: 1.1rem;
color: var(--text-primary);
border-left: 4px solid var(--accent);
padding-left: 0.8rem;
margin-bottom: 1.2rem;
font-weight: 600;
}
#recap-test-points h2 {
border-left-color: var(--tp-color);
}
.filename {
font-size: 0.7rem;
font-weight: normal;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-left: 0.5rem;
}
.section-label {
font-size: 0.75rem;
color: var(--text-secondary);
margin: 1.2rem 0 0.6rem;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 600;
}
/* ── ACCORDÉON SVG (ouvert par défaut) ── */
.svg-accordion {
margin-bottom: 1.2rem;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.svg-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-primary);
background: rgba(0,0,0,0.02);
user-select: none;
list-style: none;
transition: background 0.15s;
}
body.dark-theme .svg-summary {
background: rgba(255,255,255,0.03);
}
.svg-summary::-webkit-details-marker { display: none; }
.svg-summary::marker { display: none; }
.svg-summary:hover {
background: rgba(79, 70, 229, 0.08);
}
details[open] .svg-summary {
background: rgba(79, 70, 229, 0.05);
border-bottom: 1px solid var(--border);
}
.svg-summary::after {
content: "▸";
margin-left: auto;
font-size: 0.7rem;
color: var(--text-secondary);
transition: transform 0.2s;
}
details[open] .svg-summary::after {
transform: rotate(90deg);
}
.svg-icon {
color: var(--accent);
font-size: 0.9rem;
}
.svg-badge {
font-family: var(--font-mono);
font-size: 0.6rem;
background: var(--accent);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 6px;
letter-spacing: 0.05em;
}
.svg-wrap {
padding: 1rem;
background: white;
overflow-x: auto;
transition: background 0.3s ease;
}
body.dark-theme .svg-wrap {
background: #ffffff;
}
.svg-wrap .sheet-svg,
.svg-wrap svg {
width: 100%;
height: auto;
max-height: 900px;
display: block;
background: white;
}
/* ── TABLES ── */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
thead tr {
background: linear-gradient(135deg, var(--sidebar-bg) 0%, var(--bg-secondary) 100%);
color: var(--text-white);
}
thead th {
padding: 0.6rem 0.9rem;
text-align: left;
font-weight: 600;
font-size: 0.8rem;
letter-spacing: 0.03em;
}
tbody tr:nth-child(even) { background: rgba(0,0,0,0.02); }
body.dark-theme tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
tbody tr:hover { background: rgba(79, 70, 229, 0.08); }
tbody td { padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.tp-row { border-left: 3px solid var(--tp-color); }
.tp-ref { color: var(--tp-color); font-weight: bold; }
code {
font-family: var(--font-mono);
font-size: 0.85em;
}
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 600;
color: #333;
}
.no-doc { color: var(--text-muted); font-style: italic; }
.empty { color: var(--text-muted); font-style: italic; padding: 0.5rem 0; }
.dnp { opacity: 0.45; }
.dnp-badge {
background: var(--danger);
color: white;
font-size: 0.6rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.3rem;
vertical-align: middle;
}
small { color: var(--text-secondary); }
/* ── RESPONSIVE ── */
@media (max-width: 768px) {
#sidebar { width: 0px; display: none; }
#main { margin-left: 0; padding: 1rem; max-width: 100vw; }
.theme-toggle { bottom: 10px; right: 10px; padding: 8px 16px; font-size: 12px; }
}
"""
# JavaScript pour le changement de thème
JAVASCRIPT = """
// Gestion du thème clair/sombre
function initTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
updateThemeButton(true);
} else {
document.body.classList.remove('dark-theme');
updateThemeButton(false);
}
}
function updateThemeButton(isDark) {
const btn = document.getElementById('theme-toggle-btn');
if (btn) {
if (isDark) {
btn.innerHTML = '☀️ Thème clair';
} else {
btn.innerHTML = '🌙 Thème sombre';
}
}
}
function toggleTheme() {
if (document.body.classList.contains('dark-theme')) {
document.body.classList.remove('dark-theme');
localStorage.setItem('theme', 'light');
updateThemeButton(false);
} else {
document.body.classList.add('dark-theme');
localStorage.setItem('theme', 'dark');
updateThemeButton(true);
}
}
// Initialiser le thème au chargement
initTheme();
"""
def generate_html(sheets: list[Sheet], project_name: str = "", pcb_index_url: str = "../index.html") -> str:
from datetime import date
total_tp = sum(len(s.test_points) for s in sheets)
coverage = compute_doc_coverage(sheets)
# ── Sidebar : widget global compact ──
g_pct = coverage["global_pct"]
pct_cls = "danger" if g_pct < 50 else ("warn" if g_pct < 80 else "")
bar_col = coverage_bar_color(g_pct)
cov_html = f"""
<div id="coverage-widget">
<div class="cov-title">Couverture globale</div>
<div class="cov-global-row">
<div class="cov-pct-label {pct_cls}">{g_pct}%</div>
<div class="cov-sub">
{coverage['global_comp_doc']}/{coverage['global_comp_total']} cmp<br>
{coverage['global_sig_doc']}/{coverage['global_sig_total']} sig
</div>
</div>
<div class="cov-bar-wrap">
<div class="cov-bar-fill" style="width:{g_pct}%; background:{bar_col}"></div>
</div>
</div>
"""
# ── Bouton retour ──
back_button = f"""
<a href="{pcb_index_url}" class="back-button">
<span>←</span> Retour aux PCB
</a>
"""
# ── Sidebar : nav fusionnée ──
pct_by_name = {s["name"]: s["pct"] for s in coverage["per_sheet"]}
nav_links = '<div class="nav-section-label">Feuilles</div>\n'
for sheet in sheets:
anchor = sheet.name.lower().replace(" ", "-")
pct = pct_by_name.get(sheet.name, 100)
color = coverage_bar_color(pct)
has_svg = "◈ " if sheet.svg_content else ""
nav_links += f""" <a href="#{anchor}" class="nav-sheet-link">
<div class="nav-sheet-inner">
<span class="nav-sheet-name">{has_svg}{sheet.name}</span>
<span class="nav-sheet-pct">{pct}%</span>
</div>
<div class="nav-sheet-bar">
<div class="nav-sheet-bar-fill" style="width:{pct}%; background:{color}"></div>
</div>
</a>
"""
if total_tp:
nav_links += ' <div class="nav-section-label" style="margin-top:0.6rem">Points de test</div>\n'
nav_links += ' <a href="#recap-test-points" class="tp-link"><span class="nav-icon">🔍</span>Récapitulatif TP</a>\n'
# ── Body sections ──
body = ""
for sheet in sheets:
anchor = sheet.name.lower().replace(" ", "-")
body += f'<div id="{anchor}">' + sheet_to_html(sheet) + "</div>"
body += global_test_points_section_html(sheets)
return f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation — {project_name}</title>
<style>{CSS}</style>
</head>
<body>
<!-- ── BOUTON CHANGEMENT DE THÈME ── -->
<button id="theme-toggle-btn" class="theme-toggle" onclick="toggleTheme()">
🌙 Thème sombre
</button>
<!-- ── SIDEBAR ── -->
<aside id="sidebar">
<div id="sidebar-header">
<h1>{project_name}</h1>
<p>Généré le {date.today().isoformat()}<br>{len(sheets)} feuille(s) · {total_tp} TP</p>
</div>
{cov_html}
{back_button}
<nav id="sidebar-nav">
{nav_links}
</nav>
</aside>
<!-- ── MAIN ── -->
<main id="main">
{body}
</main>
<script>
{JAVASCRIPT}
</script>
</body>
</html>
"""
# ─────────────────────────────────────────────
# Point d'entrée
# ─────────────────────────────────────────────
def document_project(root_sch: str, sheets_dir: str, output: str | None = None, pcb_index_url: str = "../index.html") -> str:
root_path = Path(root_sch).resolve()
sheets_dir = Path(sheets_dir).resolve()
if not root_path.exists():
raise FileNotFoundError(f"Schéma racine introuvable : {root_path}")
if not sheets_dir.exists():
raise FileNotFoundError(f"Dossier introuvable : {sheets_dir}")
print(f"Schéma racine : {root_path}")
print(f"Dossier feuilles : {sheets_dir}\n")
root_content = root_path.read_text(encoding="utf-8")
root_lib = parse_lib_symbols(root_content)
sub_sheets = collect_sheets(sheets_dir, root_path)
if not sub_sheets:
print(" Aucun fichier .kicad_sch trouvé.")
return ""
print(f" {len(sub_sheets)} feuille(s) trouvée(s) :\n")
sheets = []
for name, path in sub_sheets:
print(f" Traitement : {name} ({path.relative_to(sheets_dir.parent)})")
sheet_content = path.read_text(encoding="utf-8")
sheet_lib = parse_lib_symbols(sheet_content)
merged_lib = {**root_lib, **sheet_lib}
components, test_points = parse_components_and_testpoints(sheet_content, merged_lib)
labels = parse_hierarchical_labels(sheet_content)
# ── Recherche du SVG associé ──
svg_content = ""
for search_dir in [path.parent, sheets_dir, Path(".")]:
svg_content = find_svg_for_sheet(name, search_dir)
if svg_content:
print(f" → SVG trouvé dans {search_dir}")
break
if not svg_content:
print(f" → Pas de SVG trouvé pour '{name}'")
print(f" → {len(components)} composant(s), {len(test_points)} point(s) de test, {len(labels)} signal(aux) hiérarchique(s)")
sheets.append(Sheet(
name=name,
filename=path.name,
components=components,
labels=labels,
test_points=test_points,
svg_content=svg_content,
))
output_path = Path(output) if output else None
if output_path and output_path.suffix.lower() == ".md":
result = generate_markdown_fallback(sheets, project_name=root_path.stem)
else:
result = generate_html(sheets, project_name=root_path.stem, pcb_index_url=pcb_index_url)
if output_path:
output_path.write_text(result, encoding="utf-8")
print(f"\nDocumentation écrite dans : {output_path}")
else:
print(result)
return result
def generate_markdown_fallback(sheets: list[Sheet], project_name: str = "") -> str:
lines = [
f"# Documentation composants — {project_name}", "",
"Généré automatiquement depuis les schémas KiCad.", "", "---", "",
]
coverage = compute_doc_coverage(sheets)
lines += [
f"## Couverture de documentation globale : {coverage['global_pct']}%",
f"({coverage['global_documented']}/{coverage['global_total']} éléments documentés)",
"",
"| Feuille | Couverture | Composants | Signaux |",
"|---------|------------|------------|---------|",
]
for s in coverage["per_sheet"]:
lines.append(f"| {s['name']} | {s['pct']}% | {s['comp_doc']}/{s['comp_total']} | {s['sig_doc']}/{s['sig_total']} |")
lines += ["", "---", ""]
for sheet in sheets:
lines += [f"## {sheet.name}", "", f"Fichier : `{sheet.filename}`", ""]
if sheet.labels:
lines += [
"### Signaux hiérarchiques", "",
"| Signal | Direction | Description |",
"|--------|-----------|-------------|",
]
for lbl in sheet.labels:
direction = SHAPE_TO_DIRECTION.get(lbl.shape, lbl.shape)
lines.append(f"| {lbl.name} | {direction} | {lbl.description} |")
lines.append("")
if sheet.components:
lines += [
"### Composants", "",
"| Réf. | Valeur | Description | Boîtier | Variantes |",
"|------|--------|-------------|---------|-----------|",
]
for comp in sheet.components:
fp = comp.footprint.split(":")[-1] if ":" in comp.footprint else comp.footprint
variants = ", ".join(
v.name + (" (hors BOM)" if not v.in_bom else "") + (" (DNP)" if v.dnp else "")
for v in comp.variants
) or "—"
dnp = " ⚠️DNP" if comp.dnp else ""
lines.append(f"| {comp.reference}{dnp} | {comp.value} | {comp.description} | {fp} | {variants} |")
lines.append("")
all_tps = [(s.name, tp) for s in sheets for tp in s.test_points]
if all_tps:
all_tps.sort(key=lambda x: int(re.search(r'\d+', x[1].reference).group())
if re.search(r'\d+', x[1].reference) else 0)
lines += [
"---", "",
"## Récapitulatif — Points de test (toutes feuilles)", "",
"| Réf. | Description |",
"|------|-------------|",
]
for _sheet_name, tp in all_tps:
lines.append(f"| {tp.reference} | {tp.description} |")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
import sys
if len(sys.argv) < 3:
print("Usage : py list_pinout.py <root.kicad_sch> <dossier_feuilles> [output.html|output.md]")
print("Exemple : py list_pinout.py CREPP.io.kicad_sch Modules/ documentation.html")
sys.exit(1)
# URL par défaut pour revenir à la page des PCB
pcb_url = sys.argv[4] if len(sys.argv) > 4 else "../index.html"
document_project(sys.argv[1], sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else None, pcb_url)