projet crepp_git/crepp-projects/station-meteo/hardware/recepteur/v0 · branche main
generate_page.py Voir sur GitLab
#!/usr/bin/env python3
"""
generate_page.py — page GitLab Pages d'une carte unique (1 repo = 1 carte).

Lit ./_doc (généré par export_board.py), copie les assets dans public/ et
génère public/index.html. Design repris du hub CREPP, en pleine page puisqu'il
n'y a qu'une seule carte par site ici.

Usage :
  python3 scripts/generate_page.py --output public [--hub-url https://groupe.gitlab.io/hub/]
"""
import argparse
import base64
import csv
import json
import re
import shutil
import sys
from pathlib import Path
from datetime import datetime

sys.path.insert(0, str(Path(__file__).parent))
try:
    from generate_icd import document_project
except ImportError:
    document_project = None

DOC_FILES = {
    "assembly.html": ("Plan d'assemblage", "assembly"),
    "ibom.html": ("BOM interactif", "ibom"),
    "schematic.pdf": ("Schéma", "pdf"),
}
STEP_FILE = "pcba.step"
PDF3D_FILE = "pcba-3d.pdf"

ICO = {
    "assembly": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="16" height="12" rx="1.5"/><path d="M6 5V3.5A1.5 1.5 0 0 1 7.5 2h5A1.5 1.5 0 0 1 14 3.5V5"/><path d="M6 10h8M6 13h5"/></svg>',
    "ibom": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="14" height="14" rx="1.5"/><circle cx="7" cy="7" r="1.2" fill="currentColor"/><circle cx="13" cy="7" r="1.2" fill="currentColor"/><circle cx="7" cy="13" r="1.2" fill="currentColor"/><circle cx="13" cy="13" r="1.2" fill="currentColor"/><path d="M7 8.5v3M13 8.5v3M8.5 7h3M8.5 13h3"/></svg>',
    "pdf": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 2h7l4 4v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M12 2v4h4"/><path d="M7 12h6M7 9h3"/></svg>',
    "step": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 2L3 6v8l7 4 7-4V6l-7-4z"/><path d="M3 6l7 4 7-4M10 10v8"/></svg>',
    "icd": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h12v2H4zM4 9h8M4 13h10M4 17h6"/><rect x="3" y="2" width="14" height="16" rx="1.5"/></svg>',
    "board": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="16" height="16" rx="2"/><circle cx="6" cy="6" r="1.2" fill="currentColor"/><circle cx="14" cy="6" r="1.2" fill="currentColor"/><circle cx="6" cy="14" r="1.2" fill="currentColor"/><circle cx="14" cy="14" r="1.2" fill="currentColor"/><path d="M6 7.5v5M14 7.5v5M7.5 6h5M7.5 14h5"/></svg>',
    "pinout": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="12" height="12" rx="1.5"/><path d="M2 7h2M2 10h2M2 13h2M16 7h2M16 10h2M16 13h2"/></svg>',
    "layers": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 6l8-3 8 3-8 3-8-3z"/><path d="M2 10l8 3 8-3"/><path d="M2 14l8 3 8-3"/></svg>',
    "gerber": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="10" cy="10" r="7"/><circle cx="10" cy="10" r="4"/><circle cx="10" cy="10" r="1" fill="currentColor"/></svg>',
    "cube": '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 2l7 4v8l-7 4-7-4V6l7-4z"/><path d="M3 6l7 4 7-4M10 10v8"/></svg>',
}

# ── Couches / empilage (lu directement dans le .kicad_pcb, pas besoin de kicad-cli) ──

def extract_sexpr_block(text: str, keyword: str):
    """Retourne le contenu entre parenthèses de la première occurrence de
    (keyword ...), en comptant les parenthèses plutôt qu'en supposant une
    mise en forme particulière (plus robuste que du regex sur les retours
    à la ligne, qui casse selon la version de KiCad)."""
    idx = text.find(f"({keyword}")
    if idx == -1:
        return None
    depth = 0
    for i in range(idx, len(text)):
        if text[i] == "(":
            depth += 1
        elif text[i] == ")":
            depth -= 1
            if depth == 0:
                return text[idx:i + 1]
    return None


def parse_stackup(repo: Path):
    pcb = next(repo.glob("*.kicad_pcb"), None)
    if not pcb:
        return []
    text = pcb.read_text(encoding="utf-8", errors="ignore")
    block = extract_sexpr_block(text, "stackup")
    layers = []
    if block:
        segments = re.split(r'(?=\(layer\s+")', block)
        for seg in segments:
            head = re.match(r'\(layer\s+"([^"]+)"', seg)
            if not head:
                continue
            name = head.group(1)
            if name.lower().startswith("dielectric"):
                continue
            thickness = re.search(r'\(thickness\s+([\d.]+)\)', seg)
            is_copper = ".Cu" in name or name.lower() == "copper"
            layers.append({
                "name": name,
                "thk": f"{thickness.group(1)} mm" if thickness else "—",
                "color": "#d4af37" if is_copper else "#4d7364",
            })
    if not layers:
        # repli : compte les couches cuivre déclarées dans (layers ...)
        cu_layers = re.findall(r'\(\d+\s+"?([^\s")]*\.Cu)"?', text)
        for name in cu_layers:
            layers.append({"name": f"Cuivre — {name}", "thk": "35 µm (estimé)", "color": "#d4af37"})
    return layers


def render_stackup_section(layers):
    if not layers:
        return ""
    rows = "".join(
        f'<div class="stack-row"><span class="stack-swatch" style="background:{l["color"]}"></span>'
        f'<span class="stack-name">{l["name"]}</span><span class="stack-thk">{l["thk"]}</span></div>'
        for l in layers
    )
    return f"""<div class="section-block">
  <div class="section-title">{ICO['layers']}<span>Couches &amp; empilage</span></div>
  <div class="stack-list">{rows}</div>
</div>"""


# ── Fichiers de fabrication (gerbers + perçage) ──

def human_size(path: Path) -> str:
    n = path.stat().st_size
    for unit in ("o", "Ko", "Mo", "Go"):
        if n < 1024:
            return f"{n:.0f} {unit}" if unit == "o" else f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} To"


def scan_gerbers(doc_dir: Path):
    gdir = doc_dir / "gerbers"
    files = []
    if gdir.exists():
        for f in sorted(gdir.iterdir()):
            if f.is_file():
                files.append({"name": f.name, "size": human_size(f)})
    zip_name = "gerbers.zip" if (doc_dir / "gerbers.zip").exists() else None
    # positions.csv (pick-and-place) vit dans _doc/gerbers/, donc déjà listé
    # ci-dessus et inclus dans gerbers.zip ; le DXF mécanique, lui, est un
    # fichier à part (pas destiné au fabricant de PCB mais à un logiciel de
    # CAO mécanique), donc gardé hors de gerbers/.
    dxf_name = "board-mechanical.dxf" if (doc_dir / "board-mechanical.dxf").exists() else None
    return files, zip_name, dxf_name


def render_fabrication_section(gerbers, zip_name, dxf_name=None):
    if not gerbers and not dxf_name:
        return ""
    cards = "".join(
        f'<div class="gerber-card"><span class="gname">{g["name"]}</span><span class="gsize">{g["size"]}</span></div>'
        for g in gerbers
    )
    downloads = []
    if zip_name:
        downloads.append(f'<a class="btn-primary" href="{zip_name}" download>↓ Télécharger tous les gerbers (.zip)</a>')
    if dxf_name:
        downloads.append(f'<a class="btn-secondary" href="{dxf_name}" download>↓ Contour mécanique (.dxf)</a>')
    return f"""<div class="section-block">
  <div class="section-title">{ICO['gerber']}<span>Fichiers de fabrication</span></div>
  <div class="gerber-grid">{cards}</div>
  <div class="fab-downloads">{''.join(downloads)}</div>
</div>"""


# ── Vérifications qualité (DRC électrique/fabrication PCB + ERC schéma) ──
# Le seul type d'info qu'on ne peut vraiment pas obtenir sans ouvrir KiCad et
# relancer les vérifications soi-même : un badge "0 erreur" est un vrai gage
# de qualité pour une vitrine.

def parse_quality_reports(doc_dir: Path) -> dict:
    result = {}
    for kind, filename in (("drc", "drc-report.json"), ("erc", "erc-report.json")):
        path = doc_dir / filename
        if not path.exists():
            continue
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            continue
        violations = list(data.get("violations", []))
        for sheet in data.get("sheets", []):  # structure ERC : violations par feuille
            violations.extend(sheet.get("violations", []))
        errors = sum(1 for v in violations if v.get("severity") == "error" and not v.get("excluded"))
        warnings = sum(1 for v in violations if v.get("severity") == "warning" and not v.get("excluded"))
        result[kind] = {
            "errors": errors,
            "warnings": warnings,
            "unconnected": len(data.get("unconnected_items", [])),
        }
    return result


def render_quality_section(reports: dict) -> str:
    if not reports:
        return ""
    labels = {"drc": "DRC — règles de fabrication PCB", "erc": "ERC — règles électriques du schéma"}
    cards = []
    for kind in ("drc", "erc"):
        if kind not in reports:
            continue
        r = reports[kind]
        clean = r["errors"] == 0 and r["unconnected"] == 0
        badge_class = "badge-ok" if clean else "badge-error"
        badge_text = "OK" if clean else f'{r["errors"]} erreur(s)'
        parts = [f'{r["errors"]} erreur(s)', f'{r["warnings"]} avertissement(s)']
        if r["unconnected"]:
            parts.append(f'{r["unconnected"]} non connecté(s)')
        cards.append(f"""<div class="quality-card">
  <div class="quality-head"><span>{labels[kind]}</span><span class="quality-badge {badge_class}">{badge_text}</span></div>
  <div class="quality-detail">{" · ".join(parts)}</div>
</div>""")
    if not cards:
        return ""
    return f"""<div class="section-block">
  <div class="section-title">{ICO['board']}<span>Vérifications qualité</span></div>
  <div class="quality-grid">{''.join(cards)}</div>
</div>"""


# ── Vue 3D interactive (GLB via <model-viewer>) ──

# Au-delà de cette taille, on référence le fichier au lieu de l'intégrer en
# base64 (sinon la page HTML devient énorme). Dans ce cas la vue 3D ne
# fonctionnera que servie en http (GitLab Pages), pas ouverte en file:// local.
MAX_INLINE_GLB_BYTES = 15 * 1024 * 1024  # 15 Mo


# ── Aperçu visuel des couches PCB & des schémas (SVG générés par export_board.py) ──

# Labels lisibles pour les couches de _doc/svg/layers/ (slugs définis côté
# export_board.py — dupliqué ici volontairement, generate_page.py ne dépend
# pas d'export_board.py). Un slug inconnu retombe sur une version mise en
# forme du nom de fichier, donc une nouvelle couche ajoutée côté export
# s'affiche quand même, juste avec un libellé moins soigné.
LAYER_LABELS = {
    "cuivre-dessus": "Cuivre — dessus",
    "cuivre-dessous": "Cuivre — dessous",
    "serigraphie-dessus": "Sérigraphie — dessus",
    "serigraphie-dessous": "Sérigraphie — dessous",
    "masque-dessus": "Masque de soudure — dessus",
    "masque-dessous": "Masque de soudure — dessous",
}


def load_clean_svg(path: Path) -> str:
    """Nettoie un SVG exporté par kicad-cli pour un affichage inline
    responsive : retire la déclaration XML/DOCTYPE et les width/height fixes,
    force un fond blanc (les traits de certaines couches sont sombres et
    invisibles sur le fond bleu nuit de la vitrine)."""
    raw = path.read_text(encoding="utf-8", errors="replace")
    raw = re.sub(r'<\?xml[^?]*\?>', '', raw)
    raw = re.sub(r'<!DOCTYPE[^>]*>', '', raw)
    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)
    if '<svg' in raw:
        raw = raw.replace('<svg', '<svg class="layer-svg" style="background:#fff"', 1)
    return raw.strip()


def scan_layer_svgs(doc_dir: Path) -> list[dict]:
    layer_dir = doc_dir / "svg" / "layers"
    if not layer_dir.exists():
        return []
    layers = []
    for f in sorted(layer_dir.glob("*.svg")):
        slug = f.stem
        label = LAYER_LABELS.get(slug, slug.replace("-", " ").capitalize())
        layers.append({"slug": slug, "label": label, "svg": load_clean_svg(f)})
    return layers


def scan_schematic_svgs(doc_dir: Path) -> list[dict]:
    """Les SVG de schémas (un par feuille) vivent directement dans
    _doc/svg/ — pas dans _doc/svg/layers/, qui est un sous-dossier distinct
    pour les couches PCB (voir scan_layer_svgs)."""
    svg_dir = doc_dir / "svg"
    if not svg_dir.exists():
        return []
    sheets = []
    for f in sorted(p for p in svg_dir.glob("*.svg") if p.is_file()):
        # Format attendu : <prefixe>-<NomFeuille>.svg (voir export_board.py) ;
        # on garde tout après le premier "-" comme nom lisible, en repli le
        # nom de fichier complet si le motif n'est pas respecté.
        stem = f.stem
        name = stem.split("-", 1)[1] if "-" in stem else stem
        sheets.append({"name": name, "svg": load_clean_svg(f)})
    return sheets


def render_layers_section(layers: list[dict]) -> str:
    if not layers:
        return ""
    tabs = "".join(
        f'<button class="ltab{" active" if i == 0 else ""}" onclick="showLayer(this,{i})">{l["label"]}</button>'
        for i, l in enumerate(layers)
    )
    panels = "".join(
        f'<div class="layer-panel" style="display:{"block" if i == 0 else "none"}">{l["svg"]}</div>'
        for i, l in enumerate(layers)
    )
    return f"""<div class="section-block">
  <div class="section-title">{ICO['layers']}<span>Aperçu des couches</span></div>
  <div class="layer-tabs">{tabs}</div>
  <div class="layer-viewer">{panels}</div>
</div>
<script>
function showLayer(btn, idx){{
  const viewer = btn.closest('.section-block').querySelector('.layer-viewer');
  viewer.querySelectorAll('.layer-panel').forEach((p, i) => {{ p.style.display = i === idx ? 'block' : 'none'; }});
  btn.parentElement.querySelectorAll('.ltab').forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
}}
</script>"""


def render_schematics_section(sheets: list[dict]) -> str:
    if not sheets:
        return ""
    if len(sheets) == 1:
        body = f'<div class="layer-viewer">{sheets[0]["svg"]}</div>'
    else:
        tabs = "".join(
            f'<button class="ltab{" active" if i == 0 else ""}" onclick="showSchematic(this,{i})">{s["name"]}</button>'
            for i, s in enumerate(sheets)
        )
        panels = "".join(
            f'<div class="layer-panel" style="display:{"block" if i == 0 else "none"}">{s["svg"]}</div>'
            for i, s in enumerate(sheets)
        )
        body = f'<div class="layer-tabs">{tabs}</div><div class="layer-viewer">{panels}</div>'
    return f"""<div class="section-block">
  <div class="section-title">{ICO['icd']}<span>Schémas</span></div>
  {body}
</div>
<script>
function showSchematic(btn, idx){{
  const viewer = btn.closest('.section-block').querySelector('.layer-viewer');
  viewer.querySelectorAll('.layer-panel').forEach((p, i) => {{ p.style.display = i === idx ? 'block' : 'none'; }});
  btn.parentElement.querySelectorAll('.ltab').forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
}}
</script>"""


# ── Panneau d'informations projet (title_block du .kicad_pcb + dimensions) ──

def parse_title_block(repo: Path) -> dict:
    pcb = next(repo.glob("*.kicad_pcb"), None)
    if not pcb:
        return {}
    text = pcb.read_text(encoding="utf-8", errors="ignore")
    block = extract_sexpr_block(text, "title_block")
    if not block:
        return {}
    info = {}
    for key in ("title", "date", "rev", "company"):
        m = re.search(rf'\({key}\s+"([^"]*)"', block)
        if m and m.group(1):
            info[key] = m.group(1)
    comments = []
    for m in re.finditer(r'\(comment\s+\d+\s+"([^"]*)"', block):
        if m.group(1):
            comments.append(m.group(1))
    if comments:
        info["comments"] = comments
    return info


def find_all_sexpr_blocks(text: str, keywords: tuple) -> list:
    """Comme extract_sexpr_block, mais retourne TOUTES les occurrences de
    n'importe lequel des mots-clés donnés, en comptant les parenthèses
    (un simple regex non-greedy s'arrête à la première ')' rencontrée, donc
    tronque dès la première sous-expression imbriquée — ex. (start 0 0) —
    avant même d'atteindre (layer "Edge.Cuts") plus loin dans le bloc)."""
    blocks = []
    i = 0
    pattern = re.compile(r'\((' + '|'.join(re.escape(k) for k in keywords) + r')\b')
    while True:
        m = pattern.search(text, i)
        if not m:
            break
        start = m.start()
        depth = 0
        end = start
        for j in range(start, len(text)):
            if text[j] == "(":
                depth += 1
            elif text[j] == ")":
                depth -= 1
                if depth == 0:
                    end = j + 1
                    break
        blocks.append(text[start:end])
        i = max(end, start + 1)
    return blocks


def compute_board_size_mm(repo: Path):
    """Estime les dimensions du PCB à partir des coordonnées des primitives
    Edge.Cuts. Approximatif pour les contours avec arcs (on ne suit que les
    points de contrôle, pas la courbe elle-même), suffisant pour un ordre de
    grandeur affiché sur une vitrine — pas pour du calcul de fabrication."""
    pcb = next(repo.glob("*.kicad_pcb"), None)
    if not pcb:
        return None
    text = pcb.read_text(encoding="utf-8", errors="ignore")
    xs, ys = [], []
    for chunk in find_all_sexpr_blocks(text, ("gr_line", "gr_arc", "gr_rect", "gr_poly", "gr_circle")):
        if '"Edge.Cuts"' not in chunk:
            continue
        for m in re.finditer(r'\((?:xy|start|end|center|mid)\s+(-?[\d.]+)\s+(-?[\d.]+)\)', chunk):
            xs.append(float(m.group(1)))
            ys.append(float(m.group(2)))
    if not xs or not ys:
        return None
    return {"width": round(max(xs) - min(xs), 1), "height": round(max(ys) - min(ys), 1)}


# ── Manifeste complet de _doc/ ──
# Pour qu'un serveur PHP consommant l'API GitLab n'ait besoin que d'UN seul
# appel (récupérer _doc/project-info.json en brut via
# repository/files/:path/raw) plutôt que d'un appel repository/tree
# ?recursive=true (paginé au-delà de 100 entrées, un appel par sous-dossier
# sans le paramètre recursive) suivi d'un appel par fichier. Toutes les
# infos de structure (chemins, tailles) sont déjà dans ce seul fichier.
def scan_doc_manifest(doc_dir: Path) -> list:
    manifest = []
    for f in sorted(doc_dir.rglob("*")):
        if f.is_file():
            manifest.append({
                "path": f.relative_to(doc_dir).as_posix(),
                "size": f.stat().st_size,
            })
    return manifest


def render_project_info_section(title_block: dict, board_size, layer_count: int,
                                 n_sheets: int, n_components: int, n_test_points: int) -> str:
    rows = []
    if title_block.get("rev"):
        rows.append(("Révision", title_block["rev"]))
    if title_block.get("date"):
        rows.append(("Date", title_block["date"]))
    if title_block.get("company"):
        rows.append(("Société", title_block["company"]))
    if board_size:
        rows.append(("Dimensions PCB", f'{board_size["width"]} × {board_size["height"]} mm'))
    if layer_count:
        rows.append(("Couches cuivre", str(layer_count)))
    if n_sheets:
        rows.append(("Feuilles de schéma", str(n_sheets)))
    if n_components:
        rows.append(("Composants", str(n_components)))
    if n_test_points:
        rows.append(("Points de test", str(n_test_points)))

    if not rows:
        return ""

    grid = "".join(
        f'<div class="info-cell"><span class="info-label">{label}</span>'
        f'<span class="info-value">{value}</span></div>'
        for label, value in rows
    )
    comments_html = ""
    if title_block.get("comments"):
        comments_html = "".join(f"<p>{c}</p>" for c in title_block["comments"])

    return f"""<div class="section-block">
  <div class="section-title">{ICO['board']}<span>Informations projet</span></div>
  <div class="info-grid">{grid}</div>
  {f'<div class="info-comments">{comments_html}</div>' if comments_html else ''}
</div>"""


def render_3d_section(variants_glb: list) -> str:
    """variants_glb : liste de {"name": str, "path": Path, "rel_url": str}.
    Un seul <model-viewer> dans le DOM, dont le src bascule via JS entre les
    modèles pré-encodés — pour que CHAQUE variante ait sa vue 3D dans le
    navigateur (pas seulement la version de base), en secours du PDF 3D
    (parfois capricieux selon le lecteur) et du STEP (demande un logiciel de
    CAO)."""
    entries = []
    for v in variants_glb:
        path = v["path"]
        if not path.exists():
            continue
        size = path.stat().st_size
        if size <= MAX_INLINE_GLB_BYTES:
            b64 = base64.b64encode(path.read_bytes()).decode("ascii")
            src, oversized = f"data:model/gltf-binary;base64,{b64}", False
        else:
            # fichier trop gros pour être intégré : référence directe (marche en http, pas en file://)
            src, oversized = v["rel_url"], True
        entries.append({"name": v["name"], "src": src, "oversized": oversized})

    if not entries:
        return ""

    tabs_html = ""
    if len(entries) > 1:
        tabs_html = '<div class="layer-tabs">' + "".join(
            f'<button class="ltab{" active" if i == 0 else ""}" onclick="show3D(this,{i})">{e["name"]}</button>'
            for i, e in enumerate(entries)
        ) + '</div>'

    warn = ("<br>Modèle volumineux : ne s'affichera qu'une fois la page publiée (http), pas en ouverture "
            "directe du fichier." if entries[0]["oversized"] else "")
    sources_js = ",".join(f'"{e["src"]}"' for e in entries)
    warnings_js = ",".join("true" if e["oversized"] else "false" for e in entries)

    return f"""<div class="section-block">
  <div class="section-title">{ICO['cube']}<span>Vue 3D</span></div>
  {tabs_html}
  <model-viewer id="model3d" src="{entries[0]['src']}" camera-controls auto-rotate shadow-intensity="1"
    style="width:100%;height:420px;background:#071510;border-radius:12px;display:block;"></model-viewer>
  <p class="view3d-hint" id="view3d-hint">Glisse pour tourner, molette pour zoomer.{warn}
  (STEP et PDF 3D téléchargeables ci-dessous si besoin.)</p>
</div>
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
<script>
const MODEL3D_SOURCES = [{sources_js}];
const MODEL3D_OVERSIZED = [{warnings_js}];
function show3D(btn, idx){{
  document.getElementById('model3d').src = MODEL3D_SOURCES[idx];
  const hint = document.getElementById('view3d-hint');
  hint.innerHTML = 'Glisse pour tourner, molette pour zoomer.' +
    (MODEL3D_OVERSIZED[idx] ? "<br>Modèle volumineux : ne s'affichera qu'une fois la page publiée (http), pas en ouverture directe du fichier." : '') +
    ' (STEP et PDF 3D téléchargeables ci-dessous si besoin.)';
  btn.parentElement.querySelectorAll('.ltab').forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
}}
</script>"""

# Noms de fichiers reconnus (insensible à la casse), n'importe où à la racine du repo.
PINOUT_NAME_HINTS = ("pinout", "pins", "broches", "signals", "signaux", "connecteur", "connector")
PINOUT_EXTENSIONS = (".csv", ".xlsx", ".xls", ".md", ".txt", ".tsv")


def find_pinout_file(repo: Path):
    candidates = []
    for f in repo.iterdir():
        if not f.is_file() or f.suffix.lower() not in PINOUT_EXTENSIONS:
            continue
        if any(hint in f.stem.lower() for hint in PINOUT_NAME_HINTS):
            candidates.append(f)
    return sorted(candidates)[0] if candidates else None


def parse_pinout_delimited(path: Path, delimiter=","):
    with path.open(newline="", encoding="utf-8-sig") as f:
        reader = csv.reader(f, delimiter=delimiter)
        rows = [r for r in reader if any(cell.strip() for cell in r)]
    if not rows:
        return [], []
    headers = [h.strip() for h in rows[0]]
    body = [r for r in rows[1:]]
    return headers, body


def parse_pinout_markdown(path: Path):
    lines = [l for l in path.read_text(encoding="utf-8").splitlines() if l.strip().startswith("|")]
    # ignore la ligne de séparation type |---|---|
    lines = [l for l in lines if not re.fullmatch(r"\|[\s:|-]+\|?", l.strip())]
    rows = [[c.strip() for c in l.strip().strip("|").split("|")] for l in lines]
    if not rows:
        return [], []
    return rows[0], rows[1:]


def parse_pinout_xlsx(path: Path):
    try:
        import openpyxl
    except ImportError:
        print("[avertissement] openpyxl non installé : impossible de lire le fichier .xlsx "
              "(ajoute 'pip install openpyxl' dans le job pages)")
        return [], []
    wb = openpyxl.load_workbook(path, data_only=True)
    ws = wb.worksheets[0]
    rows = [[("" if c is None else str(c)) for c in row]
            for row in ws.iter_rows(values_only=True) if any(c not in (None, "") for c in row)]
    if not rows:
        return [], []
    return [str(h) for h in rows[0]], rows[1:]


def parse_pinout(path: Path):
    ext = path.suffix.lower()
    if ext == ".csv":
        return parse_pinout_delimited(path, delimiter=",")
    if ext == ".tsv":
        return parse_pinout_delimited(path, delimiter="\t")
    if ext in (".xlsx", ".xls"):
        return parse_pinout_xlsx(path)
    if ext in (".md", ".txt"):
        return parse_pinout_markdown(path)
    return [], []


def find_project_name(repo: Path) -> str:
    pro = next(repo.glob("*.kicad_pro"), None)
    return pro.stem if pro else repo.name


def scan_doc(doc_dir: Path):
    icd = (doc_dir / "doc.html").exists()
    preview_top = (doc_dir / "view-top.png").exists()
    preview_bottom = (doc_dir / "view-bottom.png").exists()
    default_variant, others = None, []
    vdir = doc_dir / "variants"
    if vdir.exists():
        for v in sorted(p for p in vdir.iterdir() if p.is_dir()):
            files = [{"label": l, "kind": k, "url": f"variants/{v.name}/{fn}"}
                     for fn, (l, k) in DOC_FILES.items() if (v / fn).exists()]
            step_url = f"variants/{v.name}/{STEP_FILE}" if (v / STEP_FILE).exists() else None
            pdf3d_url = f"variants/{v.name}/{PDF3D_FILE}" if (v / PDF3D_FILE).exists() else None
            has_glb = (v / "pcba.glb").exists()
            if files or step_url or pdf3d_url or has_glb:
                d = {"name": v.name, "files": files, "step_url": step_url, "pdf3d_url": pdf3d_url,
                     "is_default": v.name.lower() == "default"}
                if d["is_default"]:
                    default_variant = d
                else:
                    others.append(d)
    others.sort(key=lambda x: x["name"].lower())
    variants = ([default_variant] if default_variant else []) + others
    return icd, preview_top, preview_bottom, variants


def render_pinout_section(headers, rows, source_name):
    if not headers or not rows:
        return ""
    thead = "".join(f"<th>{h}</th>" for h in headers)
    tbody = "".join(
        "<tr>" + "".join(f"<td>{cell}</td>" for cell in (row + [""] * (len(headers) - len(row)))[:len(headers)]) + "</tr>"
        for row in rows
    )
    return f"""<div class="pinout-section">
  <div class="pinout-head">
    <div class="pinout-title">{ICO['pinout']}<span>Brochage &amp; signaux</span></div>
    <div class="pinout-tools">
      <input id="pinoutSearch" class="pinout-search" type="text" placeholder="Filtrer (broche, signal, description…)">
      <a class="pinout-dl" href="{source_name}" download>↓ {source_name}</a>
    </div>
  </div>
  <div class="pinout-table-wrap">
    <table id="pinoutTable"><thead><tr>{thead}</tr></thead><tbody>{tbody}</tbody></table>
  </div>
</div>
<script>
(function(){{
  const input = document.getElementById('pinoutSearch');
  if(!input) return;
  const rows = [...document.querySelectorAll('#pinoutTable tbody tr')];
  input.addEventListener('input', () => {{
    const q = input.value.toLowerCase().trim();
    rows.forEach(r => {{ r.style.display = (!q || r.textContent.toLowerCase().includes(q)) ? '' : 'none'; }});
  }});
}})();
</script>"""


# ── Liste du matériel complète (BOM) ──

def parse_bom_csv(path: Path):
    if not path.exists():
        return []
    rows = []
    with path.open(newline="", encoding="utf-8-sig") as f:
        reader = csv.DictReader(f)
        colmap = {}
        for col in reader.fieldnames or []:
            key = col.strip().lower()
            if "ref" in key:
                colmap[col] = "ref"
            elif "value" in key or "valeur" in key:
                colmap[col] = "val"
            elif "footprint" in key or "empreinte" in key:
                colmap[col] = "fp"
            elif key in ("qty", "quantity", "qte", "qté"):
                colmap[col] = "qty"
            elif "mpn" in key or "supplier" in key or "fournisseur" in key or "manufacturer" in key:
                colmap[col] = "four"
        for row in reader:
            entry = {"ref": "", "val": "", "fp": "", "qty": "1", "four": ""}
            for col, val in row.items():
                if col in colmap:
                    entry[colmap[col]] = val or ""
            rows.append(entry)
    return rows


def render_bom_page(rows, board_name: str) -> str:
    """Page dédiée et complète pour la BOM (pas une section noyée dans la page
    principale) : recherche + tri par colonne au clic sur l'en-tête."""
    body = "".join(
        f'<tr><td class="mono">{r["ref"]}</td><td>{r["val"]}</td><td class="mono">{r["fp"]}</td>'
        f'<td>{r["qty"]}</td><td>{r["four"]}</td></tr>'
        for r in rows
    )
    return f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Liste du matériel — {board_name}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:'Inter',sans-serif;background:#000d4d;color:#e8edff;line-height:1.5;padding:2rem}}
.wrap{{max-width:1100px;margin:0 auto}}
h1{{font-size:1.6rem;font-weight:700;margin-bottom:.3rem}}
.count{{font-family:'JetBrains Mono',monospace;font-size:.8rem;color:#a8b2e8;margin-bottom:1.4rem}}
.toolbar{{margin-bottom:1.2rem}}
#search{{width:100%;max-width:340px;background:rgba(255,255,255,.08);border:1px solid rgba(168,178,232,.2);border-radius:20px;padding:.6rem 1rem;color:#e8edff;font-family:'Inter',sans-serif;font-size:.85rem;outline:none}}
#search:focus{{border-color:#6b8cff}}
table{{width:100%;border-collapse:collapse;font-size:.85rem;background:rgba(255,255,255,.03);border:1px solid rgba(168,178,232,.14);border-radius:12px;overflow:hidden}}
th{{text-align:left;font-family:'JetBrains Mono',monospace;font-size:.72rem;letter-spacing:.05em;text-transform:uppercase;color:#a8b2e8;padding:.75rem 1rem;border-bottom:1px solid rgba(168,178,232,.14);cursor:pointer;user-select:none}}
th:hover{{color:#8ba3ff}}
th .arrow{{opacity:.5;margin-left:.3rem}}
td{{padding:.6rem 1rem;border-bottom:1px solid rgba(168,178,232,.08)}}
tr:hover td{{background:rgba(107,140,255,.06)}}
.mono{{font-family:'JetBrains Mono',monospace}}
.back{{display:inline-block;margin-bottom:1.2rem;font-family:'JetBrains Mono',monospace;font-size:.75rem;color:#a8b2e8;text-decoration:none}}
.back:hover{{color:#e8edff}}
</style>
</head>
<body>
<div class="wrap">
  <a class="back" href="index.html">← Retour à la carte</a>
  <h1>{board_name} — Liste du matériel</h1>
  <div class="count">{len(rows)} référence(s)</div>
  <div class="toolbar"><input id="search" type="text" placeholder="Filtrer (réf, valeur, empreinte, fournisseur…)"></div>
  <table id="bomTable">
    <thead><tr>
      <th data-key="ref">Référence<span class="arrow">↕</span></th>
      <th data-key="val">Valeur<span class="arrow">↕</span></th>
      <th data-key="fp">Empreinte<span class="arrow">↕</span></th>
      <th data-key="qty">Qté<span class="arrow">↕</span></th>
      <th data-key="four">MPN / Fournisseur<span class="arrow">↕</span></th>
    </tr></thead>
    <tbody>{body}</tbody>
  </table>
</div>
<script>
const table = document.getElementById('bomTable');
const tbody = table.querySelector('tbody');
const search = document.getElementById('search');
let sortKey = null, sortDir = 1;

search.addEventListener('input', () => {{
  const q = search.value.toLowerCase().trim();
  [...tbody.rows].forEach(r => {{ r.style.display = (!q || r.textContent.toLowerCase().includes(q)) ? '' : 'none'; }});
}});

table.querySelectorAll('th').forEach((th, colIndex) => {{
  th.addEventListener('click', () => {{
    sortDir = (sortKey === colIndex) ? -sortDir : 1;
    sortKey = colIndex;
    const rows = [...tbody.rows];
    rows.sort((a, b) => a.cells[colIndex].textContent.localeCompare(
      b.cells[colIndex].textContent, undefined, {{numeric: true}}) * sortDir);
    rows.forEach(r => tbody.appendChild(r));
  }});
}});
</script>
</body>
</html>"""


def render_bom_button(rows) -> str:
    if not rows:
        return ""
    return (f'<a href="bom.html" class="icd-btn" target="_blank" rel="noopener">{ICO["ibom"]}'
            f'<div class="icd-text"><span class="icd-label">Liste du matériel</span>'
            f'<span class="icd-sub">{len(rows)} référence(s), triable et filtrable</span></div>'
            f'<span class="icd-arrow">↗</span></a>')


def render_doc_button(coverage, sheets) -> str:
    if not coverage:
        return ""
    n_sheets = len(sheets)
    n_tp = sum(len(s.test_points) for s in sheets)
    sub = f"{coverage['global_pct']}% documenté · {n_sheets} feuille(s)"
    if n_tp:
        sub += f" · {n_tp} point(s) de test"
    return (f'<a href="documentation.html" class="icd-btn" target="_blank" rel="noopener">{ICO["pinout"]}'
            f'<div class="icd-text"><span class="icd-label">Documentation composants &amp; signaux</span>'
            f'<span class="icd-sub">{sub}</span></div>'
            f'<span class="icd-arrow">↗</span></a>')


def pill(url, label, kind):
    return f'<a href="{url}" class="pill pill-{kind}" target="_blank" rel="noopener">{ICO.get(kind, ICO["icd"])}<span>{label}</span></a>'


def render_variant_block(v):
    pills_html = "".join(pill(f["url"], f["label"], f["kind"]) for f in v["files"])
    if v["step_url"]:
        pills_html += pill(v["step_url"], "Fichier STEP 3D", "step")
    if v["pdf3d_url"]:
        pills_html += pill(v["pdf3d_url"], "PDF 3D interactif", "pdf")
    if v["is_default"]:
        return (f'<div class="default-variant-section">'
                f'<div class="default-variant-header"><span class="default-variant-icon">⭐</span>'
                f'<span class="default-variant-label">Version de base</span></div>'
                f'<div class="pills default-pills">{pills_html}</div></div>')
    return (f'<div class="variant"><div class="vtag-row"><span class="vtag">{v["name"]}</span></div>'
            f'<div class="pills">{pills_html}</div></div>')


def render_html(board_name, icd, preview_top, preview_bottom, variants, now, hub_url, pinout_html="",
                 stackup_html="", fab_html="", view3d_html="", bom_button_html="", doc_button_html="",
                 layers_html="", schematics_html="", project_info_html="", quality_html=""):
    default_variant = next((v for v in variants if v["is_default"]), None)
    other_variants = [v for v in variants if not v["is_default"]]

    if preview_top or preview_bottom:
        first_src = "view-top.png" if preview_top else "view-bottom.png"
        tabs = ""
        if preview_top and preview_bottom:
            tabs = ('<div class="preview-tabs">'
                    '<button class="ptab active" onclick="showSide(this,\'top\')">Dessus</button>'
                    '<button class="ptab" onclick="showSide(this,\'bottom\')">Dessous</button>'
                    '</div>')
        img_html = (f'{tabs}<img id="previewImg" src="{first_src}" alt="{board_name}" loading="lazy">')
    else:
        img_html = f'<div class="preview-placeholder">{ICO["board"]}</div>'
    icd_block = ""
    if icd:
        icd_block = ('<a href="doc.html" class="icd-btn" target="_blank" rel="noopener">' + ICO["icd"] +
                      '<div class="icd-text"><span class="icd-label">Documentation du PCB</span>'
                      '<span class="icd-sub">Design et interfaces</span></div>'
                      '<span class="icd-arrow">↗</span></a>')
    default_html = render_variant_block(default_variant) if default_variant else ""
    variants_html = ""
    if other_variants:
        variants_html = ('<div class="variants-section"><div class="variants-label">Autres variantes</div>' +
                          "".join(render_variant_block(v) for v in other_variants) + '</div>')

    hub_link = f'<a class="hub-link" href="{hub_url}">← Retour au hub</a>' if hub_url else ""

    return f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{board_name} — Documentation PCB</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
:root{{
  --bg-primary:#000d4d;--bg-card:linear-gradient(135deg,#1a1f4e 0%,#131842 100%);
  --text-primary:#e8edff;--text-secondary:#a8b2e8;--text-muted:#6b74b0;
  --border:rgba(168,178,232,.14);
  --info:#6b8cff;--info-bg:rgba(107,140,255,.12);
  --success:#4caf7f;--success-bg:rgba(76,175,127,.12);
  --warning:#ffb86b;--warning-bg:rgba(255,184,107,.12);
  --gold:#ffd166;--gold-bg:rgba(255,209,102,.12);
  --step:#64b5f6;--step-bg:rgba(100,181,246,.12);
  --font-sans:'Inter',sans-serif;--font-mono:'JetBrains Mono',monospace;
  --radius:20px;--radius-sm:12px;
}}
body{{font-family:var(--font-sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.5}}
.hub-link{{font-family:var(--font-mono);font-size:.75rem;color:var(--text-secondary);text-decoration:none}}
.hub-link:hover{{color:var(--text-primary)}}
header{{max-width:960px;margin:0 auto;padding:2rem 1.5rem 0;display:flex;justify-content:space-between;align-items:center}}
h1{{font-size:clamp(1.6rem,4vw,2.4rem);font-weight:700}}
.meta{{font-family:var(--font-mono);font-size:.75rem;color:var(--text-muted);text-align:right}}
.wrapper{{max-width:960px;margin:0 auto;padding:1.5rem}}
.board-card{{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}}
.preview{{background:rgba(255,255,255,.03);border-bottom:1px solid var(--border);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem;gap:1rem}}
.preview img{{max-width:100%;max-height:420px;object-fit:contain}}
.preview-tabs{{display:flex;gap:.5rem}}
.ptab{{background:none;border:1px solid var(--border);color:var(--text-secondary);font-family:var(--font-mono);font-size:.75rem;padding:.4rem 1rem;border-radius:20px;cursor:pointer}}
.ptab.active{{background:var(--info);border-color:var(--info);color:#000d4d;font-weight:700}}
.preview-placeholder{{width:64px;height:64px;color:rgba(107,140,255,.3)}}
.board-body{{padding:1.8rem;display:flex;flex-direction:column;gap:1.2rem}}
.icd-btn{{display:flex;align-items:center;gap:.75rem;padding:.9rem 1.1rem;background:var(--info-bg);border:1px solid var(--info);border-radius:var(--radius-sm);text-decoration:none}}
.icd-btn:hover{{background:rgba(107,140,255,.2)}}
.icd-btn svg{{width:24px;height:24px;stroke:var(--info);flex-shrink:0}}
.icd-text{{display:flex;flex-direction:column;gap:.2rem;flex:1}}
.icd-label{{font-size:.95rem;font-weight:600;color:var(--info)}}
.icd-sub{{font-family:var(--font-mono);font-size:.7rem;color:var(--text-secondary)}}
.icd-arrow{{font-size:1.1rem;color:var(--info)}}
.default-variant-section{{background:var(--gold-bg);border:1px solid var(--gold);border-radius:var(--radius-sm);padding:1rem}}
.default-variant-header{{display:flex;align-items:center;gap:.5rem;margin-bottom:.85rem;padding-bottom:.5rem;border-bottom:1px solid rgba(255,209,102,.2)}}
.default-variant-label{{font-family:var(--font-mono);font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--gold)}}
.variants-section{{display:flex;flex-direction:column;gap:.85rem}}
.variants-label{{font-size:.9rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;padding-bottom:.5rem;border-bottom:1px solid var(--border)}}
.variant{{display:flex;flex-direction:column;gap:.5rem;padding:.6rem;border-radius:8px}}
.vtag{{font-family:var(--font-mono);font-size:.75rem;font-weight:500;text-transform:uppercase;letter-spacing:.5px;color:var(--text-secondary);background:rgba(168,178,232,.1);padding:.25rem .65rem;border-radius:6px}}
.pills{{display:flex;flex-wrap:wrap;gap:.6rem}}
.pill{{display:inline-flex;align-items:center;gap:.4rem;border-radius:10px;padding:.5rem .9rem;font-size:.8rem;font-weight:500;text-decoration:none;border:1px solid}}
.pill svg{{width:15px;height:15px;flex-shrink:0}}
.pill-assembly{{color:var(--success);background:var(--success-bg);border-color:var(--success)}}
.pill-ibom{{color:var(--warning);background:var(--warning-bg);border-color:var(--warning)}}
.pill-pdf{{color:var(--gold);background:var(--gold-bg);border-color:var(--gold)}}
.pill-step{{color:var(--step);background:var(--step-bg);border-color:var(--step)}}
.pinout-section{{margin-top:1.5rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}}
.pinout-head{{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.8rem;padding:1.1rem 1.4rem;border-bottom:1px solid var(--border)}}
.pinout-title{{display:flex;align-items:center;gap:.6rem;font-weight:600;font-size:1rem}}
.pinout-title svg{{width:18px;height:18px;color:#8ba3ff}}
.pinout-tools{{display:flex;align-items:center;gap:.8rem;flex-wrap:wrap}}
.pinout-search{{background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:20px;padding:.5rem .9rem;color:var(--text-primary);font-family:var(--font-sans);font-size:.82rem;outline:none;min-width:220px}}
.pinout-search:focus{{border-color:var(--info)}}
.pinout-dl{{font-family:var(--font-mono);font-size:.75rem;color:var(--text-secondary);text-decoration:none}}
.pinout-dl:hover{{color:var(--text-primary)}}
.pinout-table-wrap{{overflow-x:auto;max-height:520px;overflow-y:auto}}
#pinoutTable{{width:100%;border-collapse:collapse;font-size:.85rem}}
#pinoutTable th{{position:sticky;top:0;background:#131842;text-align:left;font-family:var(--font-mono);font-size:.72rem;letter-spacing:.05em;text-transform:uppercase;color:var(--text-secondary);padding:.7rem 1rem;border-bottom:1px solid var(--border);white-space:nowrap}}
#pinoutTable td{{padding:.6rem 1rem;border-bottom:1px solid rgba(168,178,232,.08);color:var(--text-primary)}}
#pinoutTable tr:hover td{{background:rgba(107,140,255,.06)}}
.section-block{{max-width:960px;margin:1.5rem auto 0;padding:1.4rem;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius)}}
.section-title{{display:flex;align-items:center;gap:.6rem;font-weight:600;font-size:1rem;margin-bottom:1.1rem}}
.section-title svg{{width:18px;height:18px;color:#8ba3ff}}
.stack-list{{display:flex;flex-direction:column;gap:2px}}
.stack-row{{display:grid;grid-template-columns:16px 1fr auto;gap:.9rem;align-items:center;padding:.6rem .8rem;border:1px solid var(--border);border-radius:6px}}
.stack-swatch{{width:14px;height:14px;border-radius:3px}}
.stack-thk{{font-family:var(--font-mono);font-size:.78rem;color:var(--text-secondary)}}
.gerber-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.7rem;margin-bottom:1rem}}
.gerber-card{{display:flex;flex-direction:column;gap:.2rem;border:1px solid var(--border);border-radius:8px;padding:.7rem .9rem}}
.gname{{font-family:var(--font-mono);font-size:.78rem;color:#8ba3ff}}
.gsize{{font-size:.72rem;color:var(--text-muted)}}
.btn-primary{{display:inline-block;font-family:var(--font-mono);font-size:.8rem;background:var(--info);color:#000d4d;border:none;border-radius:8px;padding:.7rem 1.1rem;text-decoration:none;font-weight:700}}
.btn-secondary{{display:inline-block;font-family:var(--font-mono);font-size:.8rem;background:none;color:var(--text-secondary);border:1px solid var(--border);border-radius:8px;padding:.65rem 1.1rem;text-decoration:none;font-weight:600}}
.fab-downloads{{display:flex;flex-wrap:wrap;gap:.6rem;margin-top:1rem}}
.quality-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:.8rem}}
.quality-card{{border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem}}
.quality-head{{display:flex;justify-content:space-between;align-items:center;font-size:.85rem;font-weight:600;color:var(--text-primary);margin-bottom:.4rem}}
.quality-badge{{font-family:var(--font-mono);font-size:.7rem;font-weight:700;padding:.2rem .6rem;border-radius:12px}}
.badge-ok{{background:rgba(52,199,89,.15);color:#34c759}}
.badge-error{{background:rgba(255,69,58,.15);color:#ff453a}}
.quality-detail{{font-family:var(--font-mono);font-size:.75rem;color:var(--text-secondary)}}
.btn-primary:hover{{background:#8ba3ff}}
.view3d-hint{{font-family:var(--font-mono);font-size:.72rem;color:var(--text-muted);margin-top:.8rem;text-align:center}}
.layer-tabs{{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem}}
.ltab{{background:none;border:1px solid var(--border);color:var(--text-secondary);font-family:var(--font-mono);font-size:.75rem;padding:.45rem 1rem;border-radius:20px;cursor:pointer}}
.ltab.active{{background:var(--info);border-color:var(--info);color:#000d4d;font-weight:700}}
.layer-viewer{{border:1px solid var(--border);border-radius:12px;overflow:hidden;background:#fff}}
.layer-svg{{width:100%;height:auto;display:block;max-height:640px}}
.info-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.7rem}}
.info-cell{{display:flex;flex-direction:column;gap:.25rem;border:1px solid var(--border);border-radius:8px;padding:.7rem .9rem}}
.info-label{{font-family:var(--font-mono);font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted)}}
.info-value{{font-size:.95rem;font-weight:600;color:var(--text-primary)}}
.info-comments{{margin-top:1rem;font-size:.85rem;color:var(--text-secondary);display:flex;flex-direction:column;gap:.3rem}}
footer{{max-width:960px;margin:2rem auto 0;padding:1.5rem;border-top:1px solid var(--border);font-family:var(--font-mono);font-size:.7rem;color:var(--text-muted)}}
</style>
</head>
<body>
<header>
  <h1>{board_name}</h1>
  <div class="meta">{hub_link}<br>Généré le {now}</div>
</header>
<div class="wrapper">
  <div class="board-card">
    <div class="preview">{img_html}</div>
    <div class="board-body">
      {icd_block}
      {bom_button_html}
      {doc_button_html}
      {default_html}
      {variants_html}
    </div>
  </div>
  {view3d_html}
  {project_info_html}
  {quality_html}
  {stackup_html}
  {schematics_html}
  {layers_html}
  {fab_html}
  {pinout_html}
</div>
<script>
function showSide(btn, side){{
  document.getElementById('previewImg').src = side === 'top' ? 'view-top.png' : 'view-bottom.png';
  document.querySelectorAll('.preview-tabs .ptab').forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
}}
</script>
<footer>Documentation générée automatiquement par la CI GitLab (kicad-cli + InteractiveHtmlBom).</footer>
</body>
</html>"""


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", default=".")
    ap.add_argument("--output", default="public")
    ap.add_argument("--hub-url", default=None, help="URL du hub central (optionnel, ajoute un lien retour)")
    args = ap.parse_args()

    repo = Path(args.source).resolve()
    doc_dir = repo / "_doc"
    out = Path(args.output)
    out.mkdir(parents=True, exist_ok=True)

    board_name = find_project_name(repo)
    icd, preview_top, preview_bottom, variants = scan_doc(doc_dir)

    if doc_dir.exists():
        for item in doc_dir.iterdir():
            dest = out / item.name
            if item.is_dir():
                shutil.copytree(item, dest, dirs_exist_ok=True)
            else:
                shutil.copy2(item, dest)

    pinout_html = ""
    pinout_file = find_pinout_file(repo)
    if pinout_file:
        headers, rows = parse_pinout(pinout_file)
        if headers and rows:
            shutil.copy2(pinout_file, out / pinout_file.name)
            pinout_html = render_pinout_section(headers, rows, pinout_file.name)
            print(f"[ok] brochage détecté : {pinout_file.name} ({len(rows)} ligne(s))")
        else:
            print(f"[avertissement] {pinout_file.name} trouvé mais vide ou illisible")

    stack_layers = parse_stackup(repo)
    stackup_html = render_stackup_section(stack_layers)
    n_copper_layers = sum(1 for l in stack_layers if l["color"] == "#d4af37")

    gerbers, gerbers_zip, dxf_name = scan_gerbers(doc_dir)
    fab_html = render_fabrication_section(gerbers, gerbers_zip, dxf_name)
    quality_html = render_quality_section(parse_quality_reports(doc_dir))

    layers_html = render_layers_section(scan_layer_svgs(doc_dir))
    schematics_html = render_schematics_section(scan_schematic_svgs(doc_dir))

    bom_rows = parse_bom_csv(doc_dir / "bom.csv")
    bom_button_html = render_bom_button(bom_rows)
    if bom_rows:
        (out / "bom.html").write_text(render_bom_page(bom_rows, board_name), encoding="utf-8")
        print(f"[ok] BOM détectée : {len(bom_rows)} ligne(s) → bom.html")
    else:
        print("[avertissement] bom.csv absent ou vide, page BOM non générée")

    default_variant = next((v for v in variants if v["is_default"]), None)
    # Toutes les variantes ayant un GLB, version de base en premier (onglet actif
    # par défaut) — pas seulement la version de base comme avant : le PDF 3D
    # étant parfois capricieux selon le lecteur, la vue navigateur doit rester
    # disponible pour chaque variante, pas juste celle par défaut.
    variants_glb = []
    ordered_names = ([default_variant["name"]] if default_variant else []) + \
                    [v["name"] for v in variants if not v["is_default"]]
    for name in ordered_names:
        glb_file = doc_dir / "variants" / name / "pcba.glb"
        if glb_file.exists():
            variants_glb.append({"name": name, "path": glb_file, "rel_url": f"variants/{name}/pcba.glb"})
    view3d_html = render_3d_section(variants_glb)

    doc_button_html = ""
    sheets_parsed = []
    coverage = {}
    sch_file = next(repo.glob("*.kicad_sch"), None)
    if document_project is None:
        print("[avertissement] generate_icd introuvable, page documentation.html non générée")
    elif sch_file is None:
        print("[avertissement] aucun .kicad_sch trouvé, page documentation.html non générée")
    else:
        try:
            _html, coverage, sheets_parsed = document_project(
                str(sch_file), str(repo), str(out / "documentation.html"), "index.html"
            )
            doc_button_html = render_doc_button(coverage, sheets_parsed)
            print(f"[ok] documentation.html générée ({coverage['global_pct']}% de couverture)")
        except Exception as e:
            print(f"[avertissement] échec de la génération de documentation.html : {e}")

    # Panneau d'infos projet : réutilise sheets_parsed (déjà calculé ci-dessus
    # par document_project) plutôt que reparser les schémas une deuxième fois.
    title_block = parse_title_block(repo)
    board_size = compute_board_size_mm(repo)
    n_sheets = len(sheets_parsed)
    n_components = sum(len(s.components) for s in sheets_parsed)
    n_test_points = sum(len(s.test_points) for s in sheets_parsed)
    project_info_html = render_project_info_section(
        title_block, board_size, n_copper_layers, n_sheets, n_components, n_test_points
    )

    # _doc/project-info.json — résumé structuré pour tools.crepp.org (lu via
    # l'API GitLab, PAS depuis public/ qui reste local et n'est jamais
    # publié). Sans ce fichier, tout le panneau "Informations projet" (titre,
    # dimensions, compteurs...) n'existerait que dans le HTML de public/,
    # inaccessible au hub. Les chemins des fichiers déjà dans _doc/ sont
    # inclus pour que le hub n'ait pas à deviner/coder en dur les noms.
    quality_reports = parse_quality_reports(doc_dir)
    project_info_payload = {
        "board_name": board_name,
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "title_block": title_block,
        "board_size_mm": board_size,
        "copper_layers": n_copper_layers,
        "sheets": n_sheets,
        "components": n_components,
        "test_points": n_test_points,
        "doc_coverage_pct": coverage.get("global_pct"),
        "variants": [v["name"] for v in variants],
        "quality": quality_reports,
        "files": {
            "gerbers_zip": "gerbers.zip" if (doc_dir / "gerbers.zip").exists() else None,
            "bom_csv": "bom.csv" if (doc_dir / "bom.csv").exists() else None,
            "drc_report": "drc-report.json" if (doc_dir / "drc-report.json").exists() else None,
            "erc_report": "erc-report.json" if (doc_dir / "erc-report.json").exists() else None,
            "mechanical_dxf": "board-mechanical.dxf" if (doc_dir / "board-mechanical.dxf").exists() else None,
            "doc_html": "doc.html" if (doc_dir / "doc.html").exists() else None,
        },
        # Manifeste complet (chemin + taille) de tout _doc/, généré en
        # dernier pour capturer tous les fichiers déjà écrits ci-dessus :
        # un serveur PHP n'a besoin d'AUCUN appel repository/tree — tout est
        # déjà là (voir scan_doc_manifest ci-dessus).
        "manifest": scan_doc_manifest(doc_dir),
    }
    (doc_dir / "project-info.json").write_text(
        json.dumps(project_info_payload, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(f"[ok] _doc/project-info.json écrit (résumé pour l'API GitLab / tools.crepp.org)")

    now = datetime.now().strftime("%d/%m/%Y à %H:%M")
    html = render_html(board_name, icd, preview_top, preview_bottom, variants, now, args.hub_url,
                        pinout_html, stackup_html, fab_html, view3d_html, bom_button_html, doc_button_html,
                        layers_html, schematics_html, project_info_html, quality_html)
    (out / "index.html").write_text(html, encoding="utf-8")
    print(f"[ok] page générée pour {board_name} → {out / 'index.html'}")


if __name__ == "__main__":
    main()