projet crepp_git/crepp-projects/station-meteo/hardware/transmetteur/base/v1.1 · branche main
pcba_doc_generator.py Voir sur GitLab
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 correspondant à la feuille :
      - soit "<prefixe>-<sheet_name>.svg"  (sous-feuilles hiérarchiques)
      - soit "<sheet_name>.svg"            (page racine : kicad-cli n'ajoute
                                            pas de préfixe pour la feuille
                                            principale)
    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 "".
    """
    patterns = [
        re.compile(rf'^.+-{re.escape(sheet_name)}\.svg$', re.IGNORECASE),
        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 pattern in patterns:
            for candidate in sorted(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 le rendre 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 class si absent
                    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),
    )


# Dossiers à ne jamais scanner : sauvegardes automatiques de KiCad, sorties
# générées, dépendances. Sans ce filtre, rglob ramasse les copies de feuilles
# qui s'y trouvent et chaque module apparaît plusieurs fois dans le menu.
IGNORED_DIR_PARTS = {
    "_doc", "public", ".git", "node_modules", "__pycache__", "scripts",
}
IGNORED_DIR_SUFFIXES = ("-backups", "-backup", ".backups")


def _is_ignored(path: Path, base: Path) -> bool:
    try:
        rel_parts = path.relative_to(base).parts[:-1]  # dossiers seulement
    except ValueError:
        rel_parts = path.parts[:-1]
    for part in rel_parts:
        if part in IGNORED_DIR_PARTS:
            return True
        if part.startswith("."):
            return True
        if any(part.endswith(suf) for suf in IGNORED_DIR_SUFFIXES):
            return True
    return False


def collect_sheets(sheets_dir: Path, root_sch: Path) -> list[tuple[str, Path]]:
    """Retourne la feuille racine EN PREMIER, puis les sous-feuilles.

    - La racine est incluse (c'est la page principale du schéma) : elle a elle
      aussi son SVG et ses éventuels composants/signaux.
    - Les doublons sont éliminés : un même nom de feuille (stem) n'est retenu
      qu'une fois, et les dossiers de sauvegarde/sortie sont ignorés.
    """
    root_resolved = root_sch.resolve()
    sheets: list[tuple[str, Path]] = [(root_sch.stem, root_sch)]
    seen_stems = {root_sch.stem.lower()}
    seen_paths = {root_resolved}

    for path in sorted(sheets_dir.rglob("*.kicad_sch")):
        resolved = path.resolve()
        if resolved in seen_paths:
            continue
        if _is_ignored(path, sheets_dir):
            continue
        stem_key = path.stem.lower()
        if stem_key in seen_stems:
            continue
        seen_stems.add(stem_key)
        seen_paths.add(resolved)
        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;
    }

    /* En-tête de tableau : fond uni sombre + texte réellement blanc.
       (Avant : dégradé partant de --sidebar-bg avec color:var(--text-white),
       or --text-white vaut #1e293b en thème clair → texte sombre sur fond
       sombre, donc bandeau illisible.) */
    thead tr {
      background: var(--sidebar-bg);
      color: #ffffff;
    }
    thead th {
      padding: 0.6rem 0.9rem;
      text-align: left;
      font-weight: 600;
      font-size: 0.8rem;
      letter-spacing: 0.03em;
      color: #ffffff;
      border-bottom: 2px solid var(--accent-primary);
    }

    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:
        # Schéma plat (pas de sous-feuilles hiérarchiques) : on documente quand
        # même la feuille racine elle-même, plutôt que de ne rien générer.
        print("  Aucune sous-feuille hiérarchique trouvée : documentation de la feuille racine seule.\n")
        components, test_points = parse_components_and_testpoints(root_content, root_lib)
        labels = parse_hierarchical_labels(root_content)
        svg_content = ""
        for search_dir in [root_path.parent, sheets_dir, Path(".")]:
            svg_content = find_svg_for_sheet(root_path.stem, search_dir)
            if svg_content:
                break
        sheets = [Sheet(
            name=root_path.stem,
            filename=root_path.name,
            components=components,
            labels=labels,
            test_points=test_points,
            svg_content=svg_content,
        )]
        print(f"  → {len(components)} composant(s), {len(test_points)} point(s) de test, {len(labels)} signal(aux) hiérarchique(s)")
    else:
        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
    coverage = compute_doc_coverage(sheets)
    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, coverage, sheets


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)