projet crepp_git/crepp-projects/tracker-solaire/hardware/v1_tht · 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 re
import shutil
import sys
from pathlib import Path
from datetime import datetime

sys.path.insert(0, str(Path(__file__).parent))
try:
    from pcba_doc_generator 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"

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
    return files, zip_name


def render_fabrication_section(gerbers, zip_name):
    if not gerbers:
        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
    )
    dl = (f'<a class="btn-primary" href="{zip_name}" download>↓ Télécharger tous les gerbers (.zip)</a>'
          if zip_name else "")
    return f"""<div class="section-block">
  <div class="section-title">{ICO['gerber']}<span>Fichiers de fabrication</span></div>
  <div class="gerber-grid">{cards}</div>
  {dl}
</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


def render_3d_section(glb_file: Path, glb_rel_url: str):
    if not glb_file or not glb_file.exists():
        return ""
    size = glb_file.stat().st_size
    if size <= MAX_INLINE_GLB_BYTES:
        b64 = base64.b64encode(glb_file.read_bytes()).decode("ascii")
        src = f"data:model/gltf-binary;base64,{b64}"
        hint = "Glisse pour tourner, molette pour zoomer."
    else:
        # fichier trop gros pour être intégré : référence directe (marche en http, pas en file://)
        src = glb_rel_url
        hint = ("Glisse pour tourner, molette pour zoomer. Modèle volumineux : la vue 3D ne "
                "s'affichera qu'une fois la page publiée (http), pas en ouverture directe du fichier.")
    return f"""<div class="section-block">
  <div class="section-title">{ICO['cube']}<span>Vue 3D</span></div>
  <model-viewer src="{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">{hint} (Version de base affichée ici ; les autres variantes restent téléchargeables en STEP ci-dessous.)</p>
</div>
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></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
            has_glb = (v / "pcba.glb").exists()
            if files or step_url or has_glb:
                d = {"name": v.name, "files": files, "step_url": step_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["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=""):
    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-primary:hover{{background:#8ba3ff}}
.view3d-hint{{font-family:var(--font-mono);font-size:.72rem;color:var(--text-muted);margin-top:.8rem;text-align:center}}
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}
  {stackup_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")

    stackup_html = render_stackup_section(parse_stackup(repo))

    gerbers, gerbers_zip = scan_gerbers(doc_dir)
    fab_html = render_fabrication_section(gerbers, gerbers_zip)

    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)
    view3d_html = ""
    if default_variant:
        glb_file = doc_dir / "variants" / default_variant["name"] / "pcba.glb"
        if glb_file.exists():
            glb_rel_url = f"variants/{default_variant['name']}/pcba.glb"
            view3d_html = render_3d_section(glb_file, glb_rel_url)

    doc_button_html = ""
    sch_file = next(repo.glob("*.kicad_sch"), None)
    if document_project is None:
        print("[avertissement] pcba_doc_generator 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}")

    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)
    (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()